feat(kebab-app): ingest_file_with_config facade (fb-31)

Single-file ingest entry. Copies bytes to _external/<hash12>.<ext>
via crate::external::copy_to_external, runs the per-medium pipeline on
that single asset (reuses ingest_with_config_opts via a SourceScope
{ root: _external/, include: [<filename>], exclude:
config.workspace.exclude }).

`.kebabignore` matches log a stderr warn line and proceed (explicit
ingest is bypass intent). Internal helper `check_kebabignore_match`
uses the `ignore` crate's GitignoreBuilder.

Returns the standard IngestReport (incremental ingest from fb-23
handles re-ingest as `unchanged`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-05-07 18:01:18 +09:00
parent 41061a38ac
commit 9b53dcb94f
2 changed files with 99 additions and 0 deletions

View File

@@ -49,6 +49,9 @@ lru = { workspace = true }
# `" foo "` collapse to one entry. Same crate kebab-normalize +
# kebab-core already use, no version drift.
unicode-normalization = "0.1"
# p9-fb-31: GitignoreBuilder for .kebabignore matching in ingest_file_with_config.
# Same version as kebab-source-fs (0.4) to avoid duplicate dep versions.
ignore = "0.4"
[dev-dependencies]
rusqlite = { workspace = true }

View File

@@ -1875,3 +1875,99 @@ pub fn doctor_with_config_path(config_path: Option<&std::path::Path>) -> anyhow:
pub fn doctor() -> anyhow::Result<DoctorReport> {
doctor_with_config_path(None)
}
/// Single-file ingest (p9-fb-31). Copies the file to
/// `<workspace.root>/_external/<blake3-12>.<ext>` and runs the
/// per-medium ingest pipeline on that single asset. Returns an
/// `IngestReport` with `scanned: 1` (and either `new: 1` or
/// `unchanged: 1` depending on whether the content hash + version
/// cascade match an existing doc — incremental ingest from p9-fb-23).
///
/// `path` may point inside or outside the workspace.
///
/// `.kebabignore` patterns matching `path` are bypassed with a stderr
/// `warn:` line — explicit ingest is intent.
#[doc(hidden)]
pub fn ingest_file_with_config(
config: kebab_config::Config,
path: &std::path::Path,
) -> anyhow::Result<IngestReport> {
if !path.exists() {
anyhow::bail!("ingest-file: source path does not exist: {}", path.display());
}
if !path.is_file() {
anyhow::bail!("ingest-file: not a regular file: {}", path.display());
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.ok_or_else(|| anyhow::anyhow!("ingest-file: source has no extension: {}", path.display()))?;
let bytes = std::fs::read(path)
.with_context(|| format!("ingest-file: read source {}", path.display()))?;
let workspace_root = config.resolve_workspace_root();
// .kebabignore check — warn but continue.
let ignore_match = check_kebabignore_match(&workspace_root, path);
if ignore_match {
eprintln!(
"warn: {} matches .kebabignore patterns; proceeding (explicit ingest bypasses ignore)",
path.display()
);
}
// Set up _external/ dir + auto-ignore line.
let external_dir = crate::external::ensure_external_dir(&workspace_root)
.context("ingest-file: ensure _external/ dir")?;
crate::external::ensure_kebabignore_entry(&workspace_root)
.context("ingest-file: append _external/ to .kebabignore")?;
// Copy bytes to _external/<hash>.<ext>.
let dest = crate::external::copy_to_external(&external_dir, &bytes, ext)
.context("ingest-file: copy to _external")?;
// Build a SourceScope that targets _external/ with include filter
// restricting walk to the single dest filename.
let filename = dest
.file_name()
.ok_or_else(|| anyhow::anyhow!("ingest-file: dest has no filename"))?
.to_string_lossy()
.into_owned();
let scope = kebab_core::SourceScope {
root: external_dir.clone(),
include: vec![filename],
exclude: config.workspace.exclude.clone(),
};
let opts = IngestOpts::default();
ingest_with_config_opts(config, scope, /* summary_only = */ false, opts)
}
/// Returns true if `source_path` matches any `.kebabignore` pattern
/// rooted at `workspace_root`. Used by `ingest_file_with_config` to
/// emit a stderr warn before bypassing the ignore.
fn check_kebabignore_match(workspace_root: &std::path::Path, source_path: &std::path::Path) -> bool {
let kebabignore = workspace_root.join(".kebabignore");
if !kebabignore.exists() {
return false;
}
let text = match std::fs::read_to_string(&kebabignore) {
Ok(s) => s,
Err(_) => return false,
};
let mut builder = ignore::gitignore::GitignoreBuilder::new(workspace_root);
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let _ = builder.add_line(None, line);
}
let matcher = match builder.build() {
Ok(m) => m,
Err(_) => return false,
};
matcher.matched(source_path, source_path.is_dir()).is_ignore()
}