Skip to main content

sqlite_graphrag/commands/
ingest.rs

1//! Handler for the `ingest` CLI subcommand.
2//!
3//! Bulk-ingests every file under a directory that matches a glob pattern.
4//! Each matched file is persisted as a separate memory using the same
5//! validation, chunking, embedding and persistence pipeline as `remember`,
6//! but executed in-process so the ONNX model is loaded only once per
7//! invocation. This is the v1.0.32 Onda 4B (finding A2) refactor that
8//! replaced a fork-spawn-per-file pipeline (every file paid the ~17s ONNX
9//! cold-start cost) with an in-process loop reusing the warm embedder
10//! (daemon when available, in-process `Embedder::new` otherwise).
11//!
12//! Memory names are derived from file basenames (kebab-case, lowercase,
13//! ASCII alphanumerics + hyphens). Output is line-delimited JSON: one
14//! object per processed file (success or error), followed by a final
15//! summary object. Designed for streaming consumption by agents.
16//!
17//! ## Incremental pipeline (v1.0.43)
18//!
19//! Phase A runs on a rayon thread pool (size = `--ingest-parallelism`):
20//! read + chunk + embed + NER per file. Results are sent immediately via a
21//! bounded `mpsc::sync_channel` to Phase B so persistence starts as soon
22//! as the first file completes — no waiting for all files to finish Phase A.
23//!
24//! Phase B runs on the main thread: receives staged files from the channel,
25//! writes to SQLite per-file (WAL absorbs individual commits), and emits
26//! NDJSON progress events to stderr as each file is persisted. `Connection`
27//! is not `Sync` so it never crosses thread boundaries.
28//!
29//! This fixes B1: with the old 2-phase design, a 50-file corpus with 27s/file
30//! NER would spend ~22min in Phase A alone, exceeding the user's 900s timeout
31//! before Phase B (and any DB writes) could begin. With this pipeline, the
32//! first file is committed within seconds of starting.
33
34use crate::chunking;
35use crate::cli::MemoryType;
36use crate::entity_type::EntityType;
37use crate::errors::AppError;
38use crate::i18n::errors_msg;
39use crate::output::{self, JsonOutputFormat};
40use crate::paths::AppPaths;
41use crate::storage::chunks as storage_chunks;
42use crate::storage::connection::{ensure_db_ready, open_rw};
43use crate::storage::entities::{NewEntity, NewRelationship};
44use crate::storage::memories::NewMemory;
45use crate::storage::{entities, memories, urls as storage_urls, versions};
46use rayon::prelude::*;
47use rusqlite::Connection;
48use serde::Serialize;
49use std::collections::BTreeSet;
50use std::path::{Path, PathBuf};
51use std::sync::mpsc;
52use unicode_normalization::UnicodeNormalization;
53
54use crate::constants::DERIVED_NAME_MAX_LEN;
55
56/// Hard cap on the numeric suffix appended for collision resolution. If 1000
57/// candidates collide we surface an error rather than loop forever.
58const MAX_NAME_COLLISION_SUFFIX: usize = 1000;
59
60#[derive(clap::Args)]
61#[command(after_long_help = "EXAMPLES:\n  \
62    # Ingest every Markdown file under ./docs as `document` memories\n  \
63    sqlite-graphrag ingest ./docs --type document\n\n  \
64    # Ingest .txt files recursively under ./notes\n  \
65    sqlite-graphrag ingest ./notes --type note --pattern '*.txt' --recursive\n\n  \
66    # Namespace derived names with a kebab-case prefix (projx-<derived>)\n  \
67    sqlite-graphrag ingest ./docs --name-prefix projx- --dry-run\n\n  \
68    # Enable automatic URL extraction (URL-regex only since v1.0.79)\n  \
69    sqlite-graphrag ingest ./big-corpus --type reference --enable-ner\n\n  \
70    # Preview file-to-name mapping without ingesting\n  \
71    sqlite-graphrag ingest ./docs --dry-run\n\n  \
72    # LLM-curated extraction via Claude Code CLI\n  \
73    sqlite-graphrag ingest ./docs --mode claude-code --recursive --json\n\n  \
74    # Resume interrupted claude-code ingest\n  \
75    sqlite-graphrag ingest ./docs --mode claude-code --resume --json\n\n  \
76    # Claude Code with budget cap and custom timeout\n  \
77    sqlite-graphrag ingest ./docs --mode claude-code --max-cost-usd 5.00 --claude-timeout 600 --json\n\n  \
78AUTHENTICATION:\n  \
79    --mode claude-code: Uses existing Claude Code authentication.\n  \
80      OAuth (Pro/Max/Team): works automatically from ~/.claude/.credentials.json\n  \
81      API key: set ANTHROPIC_API_KEY for faster startup (optional)\n\n  \
82    --mode codex: Uses existing Codex CLI authentication.\n  \
83      Device auth: run `codex auth login` first\n  \
84      API key: set OPENAI_API_KEY (optional)\n\n  \
85NOTES:\n  \
86    Each file becomes a separate memory. Names derive from file basenames\n  \
87    (kebab-case, lowercase, ASCII). Output is NDJSON: one JSON object per file,\n  \
88    followed by a final summary line with counts. Per-file errors are reported\n  \
89    inline and processing continues unless --fail-fast is set.")]
90pub struct IngestArgs {
91    /// Directory containing files to ingest.
92    #[arg(
93        value_name = "DIR",
94        help = "Directory to ingest recursively (each matching file becomes a memory)"
95    )]
96    pub dir: PathBuf,
97
98    /// Memory type stored in `memories.type` for every ingested file. Defaults to `document`.
99    #[arg(long, value_enum, default_value_t = MemoryType::Document)]
100    pub r#type: MemoryType,
101
102    /// Glob pattern matched against file basenames (default: `*.md`). Supports
103    /// `*.<ext>`, `<prefix>*`, and exact filename match.
104    #[arg(long, default_value = "*.md")]
105    pub pattern: String,
106
107    /// Recurse into subdirectories.
108    #[arg(long, default_value_t = false)]
109    pub recursive: bool,
110
111    #[arg(
112        long,
113        env = "SQLITE_GRAPHRAG_ENABLE_NER",
114        value_parser = crate::parsers::parse_bool_flexible,
115        action = clap::ArgAction::Set,
116        num_args = 0..=1,
117        default_missing_value = "true",
118        default_value = "false",
119        help = "Enable automatic URL-regex extraction (URL-regex only since v1.0.79)"
120    )]
121    pub enable_ner: bool,
122
123    /// GAP-E2E-011: generates a heuristic description from the first meaningful
124    /// line of the body, instead of "ingested from `<path>`". When
125    /// `--no-auto-describe` is passed, keeps the legacy behaviour.
126    #[arg(
127        long,
128        default_value_t = true,
129        overrides_with = "no_auto_describe",
130        help = "Derive memory description from the first meaningful body line instead of the legacy `ingested from <path>` placeholder."
131    )]
132    pub auto_describe: bool,
133    #[arg(
134        long = "no-auto-describe",
135        default_value_t = false,
136        help = "Disable `--auto-describe` and fall back to the legacy `ingested from <path>` description placeholder."
137    )]
138    pub no_auto_describe: bool,
139
140    /// Deprecated: NER is now disabled by default. Kept for backwards compatibility.
141    #[arg(long, default_value_t = false, hide = true)]
142    pub skip_extraction: bool,
143
144    /// Stop on first per-file error instead of continuing with the next file.
145    #[arg(long, default_value_t = false)]
146    pub fail_fast: bool,
147
148    /// Preview file-to-name mapping without loading model or persisting.
149    #[arg(long, default_value_t = false)]
150    pub dry_run: bool,
151
152    /// Maximum number of files to ingest (safety cap to prevent runaway ingestion).
153    #[arg(long, default_value_t = 10_000)]
154    pub max_files: usize,
155
156    /// Namespace for the ingested memories.
157    #[arg(long)]
158    pub namespace: Option<String>,
159
160    /// Database path. Falls back to `SQLITE_GRAPHRAG_DB_PATH`, then `./graphrag.sqlite`.
161    #[arg(long, env = "SQLITE_GRAPHRAG_DB_PATH")]
162    pub db: Option<String>,
163
164    #[arg(long, value_enum, default_value_t = JsonOutputFormat::Json)]
165    pub format: JsonOutputFormat,
166
167    #[arg(long, hide = true, help = "No-op; JSON is always emitted on stdout")]
168    pub json: bool,
169
170    /// Number of files to extract+embed in parallel; default = max(1, cpus/2).min(4).
171    #[arg(
172        long,
173        help = "Number of files to extract+embed in parallel; default = max(1, cpus/2).min(4)"
174    )]
175    pub ingest_parallelism: Option<usize>,
176
177    /// Force single-threaded ingest to reduce RSS pressure.
178    ///
179    /// Equivalent to `--ingest-parallelism 1`, takes precedence over any
180    /// explicit value. Recommended for environments with <4 GB available
181    /// RAM or container/cgroup constraints. Trade-off: 3-4x longer wall
182    /// time. Also honored via `SQLITE_GRAPHRAG_LOW_MEMORY=1` env var
183    /// (CLI flag has higher precedence than the env var).
184    #[arg(
185        long,
186        default_value_t = false,
187        help = "Forces single-threaded ingest (--ingest-parallelism 1) to reduce RSS pressure. \
188                Recommended for environments with <4 GB available RAM or container/cgroup \
189                constraints. Trade-off: 3-4x longer wall time. Also honored via \
190                SQLITE_GRAPHRAG_LOW_MEMORY=1 env var."
191    )]
192    pub low_memory: bool,
193
194    /// Maximum process RSS in MiB; abort if exceeded during embedding.
195    #[arg(long, default_value_t = crate::constants::DEFAULT_MAX_RSS_MB,
196          help = "Maximum process RSS in MiB; abort if exceeded during embedding (default: 8192)")]
197    pub max_rss_mb: u64,
198
199    /// G42/S3 (v1.0.79): maximum simultaneous LLM embedding subprocesses
200    /// PER FILE. Multiplies with --ingest-parallelism (files staged
201    /// concurrently), hence the conservative default of 2. The effective
202    /// value is further bounded by CPU count and available RAM.
203    #[arg(long, default_value_t = 2, value_name = "N",
204          value_parser = clap::value_parser!(u64).range(1..=32),
205          help = "Maximum simultaneous LLM embedding subprocesses per file (default: 2, clamp [1,32])")]
206    pub llm_parallelism: u64,
207
208    /// Maximum character length for derived memory names from file basenames.
209    ///
210    /// Overrides the compile-time `DERIVED_NAME_MAX_LEN` constant (default 60).
211    /// Shorter values leave more headroom for collision suffix resolution.
212    #[arg(long, default_value_t = crate::constants::DERIVED_NAME_MAX_LEN,
213          help = "Maximum length for derived memory names (default: 60)")]
214    pub max_name_length: usize,
215
216    /// v1.1.1 (P12): kebab-case prefix prepended to every derived memory name,
217    /// AFTER the basename is normalized. Namespaces a corpus inside a shared
218    /// database (e.g. `--name-prefix projx-` yields `projx-<derived>`). The
219    /// derived part's budget shrinks so the final name always respects the
220    /// 80-char name cap. Only supported with `--mode none`.
221    #[arg(
222        long,
223        value_name = "PREFIX",
224        help = "Kebab-case prefix applied to every derived memory name (e.g. 'projx-')"
225    )]
226    pub name_prefix: Option<String>,
227
228    /// Extraction mode: `none` (body-only, default) or `claude-code`/`codex`/`opencode` (LLM-curated).
229    #[arg(long, value_enum, default_value_t = IngestMode::None)]
230    pub mode: IngestMode,
231
232    /// Explicit path to the Claude Code binary (only with --mode claude-code).
233    #[arg(long, env = "SQLITE_GRAPHRAG_CLAUDE_BINARY")]
234    pub claude_binary: Option<std::path::PathBuf>,
235
236    /// Model override for Claude Code extraction (e.g. claude-sonnet-4-6).
237    #[arg(long)]
238    pub claude_model: Option<String>,
239
240    /// Resume a previously interrupted claude-code ingest from the queue DB.
241    #[arg(long, default_value_t = false)]
242    pub resume: bool,
243
244    /// Retry only failed files from a previous claude-code ingest.
245    #[arg(long, default_value_t = false)]
246    pub retry_failed: bool,
247
248    /// Keep the queue DB (.ingest-queue.sqlite) after completion.
249    #[arg(long, default_value_t = false)]
250    pub keep_queue: bool,
251
252    /// Custom path for the ingest queue DB. Default: alongside the --db database.
253    #[arg(long)]
254    pub queue_db: Option<String>,
255
256    /// Initial wait time in seconds when rate-limited (only with --mode claude-code).
257    #[arg(long, default_value_t = 60)]
258    pub rate_limit_wait: u64,
259
260    /// Maximum cumulative cost in USD before aborting (only with --mode claude-code).
261    #[arg(long)]
262    pub max_cost_usd: Option<f64>,
263
264    /// Timeout in seconds for each claude -p invocation (only with --mode claude-code).
265    #[arg(
266        long,
267        default_value_t = 300,
268        help = "Timeout in seconds for each claude -p invocation (default: 300)"
269    )]
270    pub claude_timeout: u64,
271
272    /// Explicit path to the Codex CLI binary (only with --mode codex).
273    #[arg(
274        long,
275        env = "SQLITE_GRAPHRAG_CODEX_BINARY",
276        help = "Explicit path to the Codex CLI binary (only with --mode codex)"
277    )]
278    pub codex_binary: Option<PathBuf>,
279
280    /// Model override for Codex extraction (e.g. o4-mini, gpt-5.1-codex).
281    #[arg(
282        long,
283        help = "Model override for Codex extraction (e.g. o4-mini, gpt-5.1-codex)"
284    )]
285    pub codex_model: Option<String>,
286
287    /// Timeout in seconds for each codex exec invocation.
288    #[arg(
289        long,
290        default_value_t = 300,
291        help = "Timeout in seconds for each codex exec invocation (default: 300)"
292    )]
293    pub codex_timeout: u64,
294
295    /// Path to the `opencode` binary (override PATH lookup, only with --mode opencode).
296    #[arg(long, value_name = "PATH", env = "SQLITE_GRAPHRAG_OPENCODE_BINARY")]
297    pub opencode_binary: Option<PathBuf>,
298
299    /// Model override for OpenCode extraction.
300    #[arg(
301        long,
302        value_name = "MODEL",
303        env = "SQLITE_GRAPHRAG_OPENCODE_MODEL",
304        help = "Model override for OpenCode extraction"
305    )]
306    pub opencode_model: Option<String>,
307
308    /// Timeout in seconds for each opencode run invocation.
309    #[arg(
310        long,
311        value_name = "SECONDS",
312        env = "SQLITE_GRAPHRAG_OPENCODE_TIMEOUT",
313        default_value_t = 300,
314        help = "Timeout in seconds for each opencode run invocation (default: 300)"
315    )]
316    pub opencode_timeout: u64,
317
318    /// G30: poll for the job singleton every second for up to N seconds
319    /// when another invocation holds the lock. Default: 0 (fail fast).
320    #[arg(long, value_name = "SECONDS")]
321    pub wait_job_singleton: Option<u64>,
322
323    /// G30: force acquisition of the singleton lock by removing a stale
324    /// lock file from a previously crashed invocation.
325    #[arg(long, default_value_t = false)]
326    pub force_job_singleton: bool,
327
328    /// v1.0.93 (GAP-OR-INGEST): run `enrich --operation memory-bindings`
329    /// after all files are embedded, using the active `--llm-backend`.
330    #[arg(
331        long,
332        default_value_t = false,
333        help = "Run enrich --operation memory-bindings after all files are ingested"
334    )]
335    pub enrich_after: bool,
336
337    /// GAP-SG-54: update existing memories instead of skipping them. Without
338    /// this flag a file whose derived name already exists is reported `skipped`;
339    /// with it the existing memory's body, embedding and chunks are refreshed
340    /// (the `remember --force-merge` update path applied per file).
341    #[arg(
342        long,
343        default_value_t = false,
344        help = "Update existing memories on name collision instead of skipping (idempotent re-ingest)"
345    )]
346    pub force_merge: bool,
347}
348
349/// Extraction mode for the ingest pipeline.
350#[derive(Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
351pub enum IngestMode {
352    /// Body-only ingestion without entity/relationship extraction (default).
353    None,
354    /// LLM-curated extraction via locally installed Claude Code CLI.
355    ClaudeCode,
356    /// LLM-curated extraction via locally installed OpenAI Codex CLI.
357    Codex,
358    /// LLM-curated extraction via locally installed OpenCode CLI.
359    #[value(name = "opencode")]
360    Opencode,
361}
362
363/// Returns true when the `SQLITE_GRAPHRAG_LOW_MEMORY` env var is set to a
364/// truthy value (`1`, `true`, `yes`, `on`, case-insensitive). Empty or unset
365/// values evaluate to false. Unrecognized non-empty values emit a
366/// `tracing::warn!` and evaluate to false.
367fn env_low_memory_enabled() -> bool {
368    match std::env::var("SQLITE_GRAPHRAG_LOW_MEMORY") {
369        Ok(v) if v.is_empty() => false,
370        Ok(v) => match v.to_lowercase().as_str() {
371            "1" | "true" | "yes" | "on" => true,
372            "0" | "false" | "no" | "off" => false,
373            other => {
374                tracing::warn!(
375                    target: "ingest",
376                    value = %other,
377                    "SQLITE_GRAPHRAG_LOW_MEMORY value not recognized; treating as disabled"
378                );
379                false
380            }
381        },
382        Err(_) => false,
383    }
384}
385
386/// Resolves the effective ingest parallelism honoring `--low-memory` and the
387/// `SQLITE_GRAPHRAG_LOW_MEMORY` env var.
388///
389/// Precedence:
390/// 1. `--low-memory` CLI flag forces parallelism = 1.
391/// 2. `SQLITE_GRAPHRAG_LOW_MEMORY=1` env var forces parallelism = 1.
392/// 3. Explicit `--ingest-parallelism N` (when low-memory is off).
393/// 4. Default heuristic `(cpus/2).clamp(1, 4)`.
394///
395/// When low-memory wins and the user also passed `--ingest-parallelism N>1`,
396/// emits a `tracing::warn!` advertising the override.
397fn resolve_parallelism(low_memory_flag: bool, ingest_parallelism: Option<usize>) -> usize {
398    let env_flag = env_low_memory_enabled();
399    let low_memory = low_memory_flag || env_flag;
400
401    if low_memory {
402        if let Some(n) = ingest_parallelism {
403            if n > 1 {
404                tracing::warn!(
405                    target: "ingest",
406                    requested = n,
407                    "--ingest-parallelism overridden by --low-memory; using 1"
408                );
409            }
410        }
411        if low_memory_flag {
412            tracing::info!(
413                target: "ingest",
414                source = "flag",
415                "low-memory mode enabled: forcing --ingest-parallelism 1"
416            );
417        } else {
418            tracing::info!(
419                target: "ingest",
420                source = "env",
421                "low-memory mode enabled via SQLITE_GRAPHRAG_LOW_MEMORY: forcing --ingest-parallelism 1"
422            );
423        }
424        return 1;
425    }
426
427    ingest_parallelism
428        .unwrap_or_else(|| {
429            std::thread::available_parallelism()
430                .map(|v| v.get() / 2)
431                .unwrap_or(1)
432                .clamp(1, 4)
433        })
434        .max(1)
435}
436
437#[derive(Serialize)]
438struct IngestFileEvent<'a> {
439    file: &'a str,
440    name: &'a str,
441    status: &'a str,
442    /// True when the derived name was truncated to fit `DERIVED_NAME_MAX_LEN`. False otherwise.
443    truncated: bool,
444    /// Original derived name before truncation; only present when `truncated=true`.
445    #[serde(skip_serializing_if = "Option::is_none")]
446    original_name: Option<String>,
447    /// Original file basename (without extension); only present when it differs from `name`.
448    #[serde(skip_serializing_if = "Option::is_none")]
449    original_filename: Option<&'a str>,
450    #[serde(skip_serializing_if = "Option::is_none")]
451    error: Option<String>,
452    #[serde(skip_serializing_if = "Option::is_none")]
453    memory_id: Option<i64>,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    action: Option<String>,
456    /// Byte length of the body ingested; 0 when not yet read (e.g. skip or dry-run events).
457    body_length: usize,
458    /// v1.0.84 (ADR-0042): discriminator of the LLM backend that actually
459    /// ran the live embedding. `"claude" | "codex" | "none"`. Absent on
460    /// the wire when `None` (kept for happy-path envelope cleanliness, or
461    /// when the file never reached the embed phase due to duplication/error).
462    #[serde(skip_serializing_if = "Option::is_none")]
463    backend_invoked: Option<&'a str>,
464}
465
466/// GAP-SG-06: per-file budget assessment emitted during `--dry-run` so the
467/// operator sees chunk and token counts (and how many sub-memories an
468/// auto-split would create) before running a real ingest.
469#[derive(Serialize)]
470struct IngestDryRunBudget<'a> {
471    budget: bool,
472    file: &'a str,
473    name: &'a str,
474    bytes: usize,
475    chunk_count: usize,
476    token_count: usize,
477    partition_count: usize,
478    exceeds_limits: bool,
479}
480
481#[derive(Serialize)]
482struct IngestSummary {
483    summary: bool,
484    dir: String,
485    pattern: String,
486    recursive: bool,
487    files_total: usize,
488    files_succeeded: usize,
489    files_failed: usize,
490    files_skipped: usize,
491    elapsed_ms: u64,
492}
493
494/// Outcome of a successful per-file ingest, used to build the NDJSON event.
495#[derive(Debug)]
496struct FileSuccess {
497    memory_id: i64,
498    action: String,
499    body_length: usize,
500    backend_invoked: Option<&'static str>,
501}
502
503/// NDJSON progress event emitted to stderr after each file completes Phase A.
504/// Schema version 1; consumers should check `schema_version` before parsing.
505#[derive(Serialize)]
506struct StageProgressEvent<'a> {
507    schema_version: u8,
508    event: &'a str,
509    path: &'a str,
510    ms: u64,
511    entities: usize,
512    relationships: usize,
513}
514
515/// All artefacts pre-computed by Phase A (CPU-bound, runs on rayon thread pool).
516/// Phase B persists these to SQLite on the main thread in submission order.
517struct StagedFile {
518    body: String,
519    body_hash: String,
520    snippet: String,
521    name: String,
522    description: String,
523    embedding: Option<Vec<f32>>,
524    chunk_embeddings: Option<Vec<Vec<f32>>>,
525    chunks_info: Vec<crate::chunking::Chunk>,
526    entities: Vec<NewEntity>,
527    relationships: Vec<NewRelationship>,
528    entity_embeddings: Option<Vec<Vec<f32>>>,
529    urls: Vec<crate::extraction::ExtractedUrl>,
530    /// v1.0.84 (ADR-0042): discriminator of the LLM backend that actually
531    /// ran the body embedding. `None` when the parallel batch
532    /// embed_passages_parallel_local fell back to different backends
533    /// across chunks (there is no single stable discriminator).
534    backend_invoked: Option<&'static str>,
535}
536
537/// Phase A worker: reads, chunks, embeds and extracts NER for one file.
538/// Never touches the database — safe to run on any rayon thread.
539// G42/S3 added `llm_parallelism` as the 8th parameter; grouping the
540// stage knobs into a struct is a wider refactor than the surgical
541// scope of v1.0.79 allows.
542#[allow(clippy::too_many_arguments)]
543fn stage_file(
544    _idx: usize,
545    path: &Path,
546    name: &str,
547    paths: &AppPaths,
548    enable_ner: bool,
549    max_rss_mb: u64,
550    llm_parallelism: usize,
551    llm_backend: crate::cli::LlmBackendChoice,
552    embedding_backend: crate::cli::EmbeddingBackendChoice,
553    auto_describe: bool,
554) -> Result<Vec<StagedFile>, AppError> {
555    use crate::constants::*;
556
557    if name.len() > MAX_MEMORY_NAME_LEN {
558        return Err(AppError::LimitExceeded(
559            crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
560        ));
561    }
562    if name.starts_with("__") {
563        return Err(AppError::Validation(
564            crate::i18n::validation::reserved_name(),
565        ));
566    }
567    {
568        let slug_re = crate::constants::name_slug_regex();
569        if !slug_re.is_match(name) {
570            return Err(AppError::Validation(crate::i18n::validation::name_kebab(
571                name,
572            )));
573        }
574    }
575
576    let file_size = std::fs::metadata(path).map_err(AppError::Io)?.len();
577    if file_size > MAX_MEMORY_BODY_LEN as u64 {
578        return Err(AppError::BodyTooLarge {
579            bytes: file_size,
580            limit: MAX_MEMORY_BODY_LEN as u64,
581        });
582    }
583    let raw_body = std::fs::read_to_string(path).map_err(AppError::Io)?;
584    if raw_body.len() > MAX_MEMORY_BODY_LEN {
585        return Err(AppError::BodyTooLarge {
586            bytes: raw_body.len() as u64,
587            limit: MAX_MEMORY_BODY_LEN as u64,
588        });
589    }
590    if raw_body.trim().is_empty() {
591        return Err(AppError::Validation(crate::i18n::validation::empty_body()));
592    }
593
594    let description = if auto_describe {
595        crate::commands::ingest_heuristics::extract_heuristic_description(
596            &raw_body,
597            Some(&path.display().to_string()),
598        )
599    } else {
600        format!("ingested from {}", path.display())
601    };
602    if description.len() > MAX_MEMORY_DESCRIPTION_LEN {
603        return Err(AppError::Validation(
604            crate::i18n::validation::description_exceeds(MAX_MEMORY_DESCRIPTION_LEN),
605        ));
606    }
607
608    // GAP-SG-04/07: auto-split a body that exceeds the single-memory budgets
609    // (bytes, chunk count, token count) into section-aligned sub-memories so
610    // ingestion never fails on an oversized document. A body that fits returns
611    // a single partition under the original name.
612    // Token-ceiling enforcement for ingest lives in chunking::fits_single_partition
613    // (EMBEDDING_REQUEST_MAX_TOKENS): the auto-split keeps every partition under
614    // the cap, so no edge rejection is needed here (v1.1.02).
615    let partitions = chunking::split_body_by_sections(&raw_body);
616    let total_parts = partitions.len();
617    let mut staged = Vec::with_capacity(total_parts);
618    for (part_idx, part_body) in partitions.into_iter().enumerate() {
619        let part_name = if total_parts == 1 {
620            name.to_string()
621        } else {
622            format!("{name}-part-{}", part_idx + 1)
623        };
624        if part_name.len() > MAX_MEMORY_NAME_LEN {
625            return Err(AppError::LimitExceeded(
626                crate::i18n::validation::name_length(MAX_MEMORY_NAME_LEN),
627            ));
628        }
629        let part_description = if total_parts == 1 {
630            description.clone()
631        } else {
632            partition_description(&description, part_idx + 1, total_parts)
633        };
634        staged.push(stage_one_body(
635            part_body,
636            part_name,
637            part_description,
638            paths,
639            enable_ner,
640            max_rss_mb,
641            llm_parallelism,
642            llm_backend,
643            embedding_backend,
644        )?);
645    }
646    Ok(staged)
647}
648
649/// Builds a partition description by appending a `(part i/n)` marker, trimming
650/// the base (on a char boundary) when the marker would push it past
651/// [`crate::constants::MAX_MEMORY_DESCRIPTION_LEN`].
652fn partition_description(base: &str, part: usize, total: usize) -> String {
653    let suffix = format!(" (part {part}/{total})");
654    let max = crate::constants::MAX_MEMORY_DESCRIPTION_LEN;
655    if base.len() + suffix.len() <= max {
656        return format!("{base}{suffix}");
657    }
658    let mut cut = max.saturating_sub(suffix.len()).min(base.len());
659    while cut > 0 && !base.is_char_boundary(cut) {
660        cut -= 1;
661    }
662    format!("{}{}", &base[..cut], suffix)
663}
664
665/// Stages a single body (one memory) into a [`StagedFile`]: NER extraction,
666/// chunking, embedding and entity embedding. Extracted from `stage_file` so the
667/// GAP-SG-04/07 auto-split path stages each partition independently.
668#[allow(clippy::too_many_arguments)]
669fn stage_one_body(
670    raw_body: String,
671    name: String,
672    description: String,
673    paths: &AppPaths,
674    enable_ner: bool,
675    max_rss_mb: u64,
676    llm_parallelism: usize,
677    llm_backend: crate::cli::LlmBackendChoice,
678    embedding_backend: crate::cli::EmbeddingBackendChoice,
679) -> Result<StagedFile, AppError> {
680    use crate::constants::*;
681
682    let mut extracted_entities: Vec<NewEntity> = Vec::with_capacity(30);
683    let mut extracted_relationships: Vec<NewRelationship> = Vec::with_capacity(50);
684    let mut extracted_urls: Vec<crate::extraction::ExtractedUrl> = Vec::with_capacity(4);
685    if enable_ner {
686        match crate::extraction::extract_graph_auto(&raw_body, paths) {
687            Ok(extracted) => {
688                extracted_urls = extracted.urls;
689                // v1.0.76: ExtractionResult.entities is now
690                // Vec<ExtractedEntity>, not Vec<NewEntity>. Convert
691                // via name + type only; start/end offsets are not
692                // carried forward into the storage layer.
693                extracted_entities = extracted
694                    .entities
695                    .into_iter()
696                    .map(|e| NewEntity {
697                        name: e.name,
698                        entity_type: crate::entity_type::EntityType::Concept,
699                        description: None,
700                    })
701                    .collect();
702                // v1.0.76: relationships are no longer in the
703                // ExtractionResult struct; the LLM backend returns
704                // them in its own payload. The default build is
705                // URL-only extraction.
706                extracted_relationships.clear();
707
708                if extracted_entities.len() > max_entities_per_memory() {
709                    extracted_entities.truncate(max_entities_per_memory());
710                }
711                if extracted_relationships.len() > max_relationships_per_memory() {
712                    extracted_relationships.truncate(max_relationships_per_memory());
713                }
714            }
715            Err(e) => {
716                tracing::warn!(
717                    target: "ingest",
718                    file = %name,
719                    "auto-extraction failed (graceful degradation): {e:#}"
720                );
721            }
722        }
723    }
724
725    for rel in &mut extracted_relationships {
726        rel.relation = crate::parsers::normalize_relation(&rel.relation);
727        if let Err(e) = crate::parsers::validate_relation_format(&rel.relation) {
728            return Err(AppError::Validation(format!(
729                "{e} for relationship '{}' -> '{}'",
730                rel.source, rel.target
731            )));
732        }
733        crate::parsers::warn_if_non_canonical(&rel.relation);
734        if !(0.0..=1.0).contains(&rel.strength) {
735            return Err(AppError::Validation(format!(
736                "invalid strength {} for relationship '{}' -> '{}'; expected value in [0.0, 1.0]",
737                rel.strength, rel.source, rel.target
738            )));
739        }
740    }
741
742    let body_hash = blake3::hash(raw_body.as_bytes()).to_hex().to_string();
743    let snippet: String = raw_body.chars().take(200).collect();
744
745    let chunks_info = chunking::split_into_chunks_hierarchical(&raw_body);
746    if chunks_info.len() > REMEMBER_MAX_SAFE_MULTI_CHUNKS {
747        return Err(AppError::TooManyChunks {
748            chunks: chunks_info.len(),
749            limit: REMEMBER_MAX_SAFE_MULTI_CHUNKS,
750        });
751    }
752
753    let mut chunk_embeddings_opt: Option<Vec<Vec<f32>>> = None;
754    let skip_embed = crate::embedder::should_skip_embedding_on_failure();
755    // v1.0.84 (ADR-0042): tuple (Vec<f32>, LlmBackendKind) — extrai o
756    // backend que efetivamente rodou para popular `backend_invoked` no
757    // envelope NDJSON por arquivo.
758    let (embedding, backend_invoked): (Option<Vec<f32>>, Option<&'static str>) = if chunks_info
759        .len()
760        == 1
761    {
762        match crate::embedder::embed_passage_with_embedding_choice(
763            &paths.models,
764            &raw_body,
765            embedding_backend,
766            llm_backend,
767        ) {
768            Ok((v, k)) => (Some(v), Some(k.as_str())),
769            // v1.1.2 (Gap 2): typed payload rejections are permanent and
770            // must not be swallowed by --skip-embedding-on-failure.
771            Err(
772                e @ (AppError::Validation(_)
773                | AppError::BodyTooLarge { .. }
774                | AppError::TooManyTokens { .. }),
775            ) => return Err(e),
776            Err(e) if skip_embed => {
777                tracing::warn!(error = %e, file = %name, "ingest: embedding failed; --skip-embedding-on-failure active, persisting without embedding");
778                (None, None)
779            }
780            Err(e) => return Err(e),
781        }
782    } else {
783        // G42/S2+S3 (v1.0.79): batched bounded fan-out replaces the
784        // serial per-chunk subprocess loop.
785        let chunk_texts: Vec<String> = chunks_info
786            .iter()
787            .map(|c| chunking::chunk_text(&raw_body, c).to_string())
788            .collect();
789        if let Some(rss) = crate::memory_guard::current_process_memory_mb() {
790            if rss > max_rss_mb {
791                tracing::error!(
792                    target: "ingest",
793                    rss_mb = rss,
794                    max_rss_mb = max_rss_mb,
795                    file = %name,
796                    "RSS exceeded --max-rss-mb threshold; aborting to prevent system instability"
797                );
798                return Err(AppError::LowMemory {
799                    available_mb: crate::memory_guard::available_memory_mb(),
800                    required_mb: max_rss_mb,
801                });
802            }
803        }
804        match crate::embedder::embed_passages_parallel_with_embedding_choice(
805            &paths.models,
806            &chunk_texts,
807            llm_parallelism,
808            crate::embedder::chunk_embed_batch_size(),
809            embedding_backend,
810            llm_backend,
811        ) {
812            Ok(chunk_embeddings) => {
813                let aggregated = chunking::aggregate_embeddings(&chunk_embeddings);
814                chunk_embeddings_opt = Some(chunk_embeddings);
815                // v1.0.84 (ADR-0042): batch paralelo não retorna discriminador
816                // único por chamada. Conservadoramente, populamos None aqui.
817                (Some(aggregated), None)
818            }
819            Err(
820                e @ (AppError::Validation(_)
821                | AppError::BodyTooLarge { .. }
822                | AppError::TooManyTokens { .. }),
823            ) => return Err(e),
824            Err(e) if skip_embed => {
825                tracing::warn!(error = %e, file = %name, "ingest: chunk embedding failed; --skip-embedding-on-failure active, persisting without embedding");
826                (None, None)
827            }
828            Err(e) => return Err(e),
829        }
830    };
831
832    // G42/S2+A4 (v1.0.79): entity names use the short-text batch profile.
833    let entity_texts: Vec<String> = extracted_entities
834        .iter()
835        .map(|entity| match &entity.description {
836            Some(desc) => format!("{} {}", entity.name, desc),
837            None => entity.name.clone(),
838        })
839        .collect();
840    // G56 (v1.0.80): ingest reuses canonical entity names across many
841    // memories (e.g. `sqlite-graphrag`, `claude-code`); the in-process
842    // cache collapses the repeated LLM calls into one per unique text.
843    let entity_embeddings_opt = match crate::embedder::embed_entity_texts_cached(
844        &paths.models,
845        &entity_texts,
846        llm_parallelism,
847        embedding_backend,
848        llm_backend,
849    ) {
850        Ok((entity_embeddings, embed_cache_stats)) => {
851            if embed_cache_stats.hits > 0 {
852                tracing::debug!(
853                    hits = embed_cache_stats.hits,
854                    misses = embed_cache_stats.misses,
855                    requested = embed_cache_stats.requested,
856                    "G56: entity embed cache hit (ingest)"
857                );
858            }
859            Some(entity_embeddings)
860        }
861        Err(e) if skip_embed => {
862            tracing::warn!(error = %e, file = %name, "ingest: entity embedding failed; --skip-embedding-on-failure active");
863            None
864        }
865        Err(e) => return Err(e),
866    };
867
868    Ok(StagedFile {
869        body: raw_body,
870        body_hash,
871        snippet,
872        name,
873        description,
874        embedding,
875        chunk_embeddings: chunk_embeddings_opt,
876        chunks_info,
877        entities: extracted_entities,
878        relationships: extracted_relationships,
879        entity_embeddings: entity_embeddings_opt,
880        urls: extracted_urls,
881        backend_invoked,
882    })
883}
884
885/// Links the staged entities and relationships to `memory_id` within `tx`.
886/// Shared by the create and `--force-merge` update paths so the graph-binding
887/// logic lives in one place.
888fn link_staged_graph(
889    tx: &Connection,
890    namespace: &str,
891    memory_id: i64,
892    staged: &StagedFile,
893) -> Result<(), AppError> {
894    if staged.entities.is_empty() && staged.relationships.is_empty() {
895        return Ok(());
896    }
897    for (idx, entity) in staged.entities.iter().enumerate() {
898        let entity_id = entities::upsert_entity(tx, namespace, entity)?;
899        if let Some(ref entity_embeddings) = staged.entity_embeddings {
900            if let Some(entity_embedding) = entity_embeddings.get(idx) {
901                entities::upsert_entity_vec(
902                    tx,
903                    entity_id,
904                    namespace,
905                    entity.entity_type,
906                    entity_embedding,
907                    &entity.name,
908                )?;
909            }
910        }
911        entities::link_memory_entity(tx, memory_id, entity_id)?;
912    }
913    let entity_types: std::collections::HashMap<&str, EntityType> = staged
914        .entities
915        .iter()
916        .map(|entity| (entity.name.as_str(), entity.entity_type))
917        .collect();
918
919    let mut affected_entity_ids: std::collections::HashSet<i64> = std::collections::HashSet::new();
920    for entity in &staged.entities {
921        if let Some(eid) = entities::find_entity_id(tx, namespace, &entity.name)? {
922            affected_entity_ids.insert(eid);
923        }
924    }
925
926    for rel in &staged.relationships {
927        let source_entity = NewEntity {
928            name: rel.source.clone(),
929            entity_type: entity_types
930                .get(rel.source.as_str())
931                .copied()
932                .unwrap_or(EntityType::Concept),
933            description: None,
934        };
935        let target_entity = NewEntity {
936            name: rel.target.clone(),
937            entity_type: entity_types
938                .get(rel.target.as_str())
939                .copied()
940                .unwrap_or(EntityType::Concept),
941            description: None,
942        };
943        let source_id = entities::upsert_entity(tx, namespace, &source_entity)?;
944        let target_id = entities::upsert_entity(tx, namespace, &target_entity)?;
945        let rel_id = entities::upsert_relationship(tx, namespace, source_id, target_id, rel)?;
946        entities::link_memory_relationship(tx, memory_id, rel_id)?;
947        affected_entity_ids.insert(source_id);
948        affected_entity_ids.insert(target_id);
949    }
950
951    for &eid in &affected_entity_ids {
952        entities::recalculate_degree(tx, eid)?;
953    }
954    Ok(())
955}
956
957/// Phase B: persists one `StagedFile` to the database on the main thread.
958///
959/// GAP-SG-54: when `force_merge` is true an existing memory with the same name
960/// is UPDATED (body/embedding/chunks/graph refreshed) instead of being rejected
961/// as a duplicate. GAP-SG-55: a memory whose `body_hash` already exists under a
962/// DIFFERENT name is skipped (content-level dedup) so divergent derived names do
963/// not duplicate identical content.
964fn persist_staged(
965    conn: &mut Connection,
966    namespace: &str,
967    memory_type: &str,
968    staged: StagedFile,
969    force_merge: bool,
970) -> Result<FileSuccess, AppError> {
971    {
972        let active_count: u32 = conn.query_row(
973            "SELECT COUNT(DISTINCT namespace) FROM memories WHERE deleted_at IS NULL",
974            [],
975            |r| r.get::<_, i64>(0).map(|v| v as u32),
976        )?;
977        let ns_exists: bool = conn.query_row(
978            "SELECT EXISTS(SELECT 1 FROM memories WHERE namespace = ?1 AND deleted_at IS NULL)",
979            rusqlite::params![namespace],
980            |r| r.get::<_, i64>(0).map(|v| v > 0),
981        )?;
982        if !ns_exists && active_count >= crate::constants::MAX_NAMESPACES_ACTIVE {
983            return Err(AppError::NamespaceError(format!(
984                "active namespace limit of {} exceeded while creating '{namespace}'",
985                crate::constants::MAX_NAMESPACES_ACTIVE
986            )));
987        }
988    }
989
990    let existing_memory = memories::find_by_name(conn, namespace, &staged.name)?;
991    let duplicate_hash_id = memories::find_by_hash(conn, namespace, &staged.body_hash)?;
992
993    let new_memory = NewMemory {
994        namespace: namespace.to_string(),
995        name: staged.name.clone(),
996        memory_type: memory_type.to_string(),
997        description: staged.description.clone(),
998        body: staged.body.clone(),
999        body_hash: staged.body_hash.clone(),
1000        session_id: None,
1001        source: "agent".to_string(),
1002        metadata: serde_json::json!({}),
1003    };
1004    let body_length = new_memory.body.len();
1005    let metadata_json = serde_json::to_string(&new_memory.metadata)?;
1006
1007    match existing_memory {
1008        Some((existing_id, _updated_at, _version)) => {
1009            if !force_merge {
1010                return Err(AppError::Duplicate(errors_msg::duplicate_memory(
1011                    &staged.name,
1012                    namespace,
1013                )));
1014            }
1015
1016            // GAP-SG-54: --force-merge update path. Refresh body, embedding,
1017            // chunks and graph bindings of the existing memory.
1018            let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1019
1020            let (old_name, old_desc, old_body): (String, String, String) = tx.query_row(
1021                "SELECT name, description, body FROM memories WHERE id = ?1",
1022                rusqlite::params![existing_id],
1023                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1024            )?;
1025
1026            let next_v = versions::next_version(&tx, existing_id)?;
1027            memories::update(&tx, existing_id, &new_memory, None)?;
1028            memories::sync_fts_after_update(
1029                &tx,
1030                existing_id,
1031                &old_name,
1032                &old_desc,
1033                &old_body,
1034                &staged.name,
1035                &staged.description,
1036                &new_memory.body,
1037            )?;
1038            versions::insert_version(
1039                &tx,
1040                existing_id,
1041                next_v,
1042                &staged.name,
1043                memory_type,
1044                &staged.description,
1045                &new_memory.body,
1046                &metadata_json,
1047                None,
1048                "edit",
1049            )?;
1050
1051            // Re-index chunks: drop the old slices then re-insert the staged set.
1052            storage_chunks::delete_chunks(&tx, existing_id)?;
1053            if let Some(ref emb) = staged.embedding {
1054                memories::upsert_vec(
1055                    &tx,
1056                    existing_id,
1057                    namespace,
1058                    memory_type,
1059                    emb,
1060                    &staged.name,
1061                    &staged.snippet,
1062                )?;
1063            }
1064            if staged.chunks_info.len() > 1 {
1065                storage_chunks::insert_chunk_slices(
1066                    &tx,
1067                    existing_id,
1068                    &new_memory.body,
1069                    &staged.chunks_info,
1070                )?;
1071                if let Some(ref chunk_embeddings) = staged.chunk_embeddings {
1072                    for (i, emb) in chunk_embeddings.iter().enumerate() {
1073                        storage_chunks::upsert_chunk_vec(
1074                            &tx,
1075                            i as i64,
1076                            existing_id,
1077                            i as i32,
1078                            emb,
1079                        )?;
1080                    }
1081                }
1082            }
1083
1084            link_staged_graph(&tx, namespace, existing_id, &staged)?;
1085            tx.commit()?;
1086
1087            Ok(FileSuccess {
1088                memory_id: existing_id,
1089                action: "updated".to_string(),
1090                body_length,
1091                backend_invoked: staged.backend_invoked,
1092            })
1093        }
1094        None => {
1095            // GAP-SG-55: identical content already stored under a different name
1096            // → skip creating a duplicate (reported as `skipped` by the caller).
1097            if let Some(hash_id) = duplicate_hash_id {
1098                return Err(AppError::Duplicate(format!(
1099                    "identical body already stored as memory id {hash_id} (dedup by body_hash); skipping '{}'",
1100                    staged.name
1101                )));
1102            }
1103
1104            let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1105            let memory_id = memories::insert(&tx, &new_memory)?;
1106            versions::insert_version(
1107                &tx,
1108                memory_id,
1109                1,
1110                &staged.name,
1111                memory_type,
1112                &staged.description,
1113                &new_memory.body,
1114                &metadata_json,
1115                None,
1116                "create",
1117            )?;
1118            if let Some(ref emb) = staged.embedding {
1119                memories::upsert_vec(
1120                    &tx,
1121                    memory_id,
1122                    namespace,
1123                    memory_type,
1124                    emb,
1125                    &staged.name,
1126                    &staged.snippet,
1127                )?;
1128            }
1129            if staged.chunks_info.len() > 1 {
1130                storage_chunks::insert_chunk_slices(
1131                    &tx,
1132                    memory_id,
1133                    &new_memory.body,
1134                    &staged.chunks_info,
1135                )?;
1136                if let Some(ref chunk_embeddings) = staged.chunk_embeddings {
1137                    for (i, emb) in chunk_embeddings.iter().enumerate() {
1138                        storage_chunks::upsert_chunk_vec(&tx, i as i64, memory_id, i as i32, emb)?;
1139                    }
1140                }
1141            }
1142            link_staged_graph(&tx, namespace, memory_id, &staged)?;
1143            tx.commit()?;
1144
1145            if !staged.urls.is_empty() {
1146                let url_entries: Vec<storage_urls::MemoryUrl> = staged
1147                    .urls
1148                    .into_iter()
1149                    .map(|u| storage_urls::MemoryUrl {
1150                        url: u.url,
1151                        offset: Some(u.start as i64),
1152                    })
1153                    .collect();
1154                let _ = storage_urls::insert_urls(conn, memory_id, &url_entries);
1155            }
1156
1157            Ok(FileSuccess {
1158                memory_id,
1159                action: "created".to_string(),
1160                body_length,
1161                backend_invoked: staged.backend_invoked,
1162            })
1163        }
1164    }
1165}
1166
1167// ---------------------------------------------------------------------------
1168// G20: mode-conditional flag validation
1169// ---------------------------------------------------------------------------
1170
1171/// True when a scalar value matches its declared default. Local
1172/// re-declaration (also defined in ) to keep this module
1173/// self-contained for the G20 fix.
1174fn is_at_default<T: PartialEq>(value: T, default: T) -> bool {
1175    value == default
1176}
1177
1178/// G20: validate that flags for one LLM provider were not passed when
1179/// the operator selected a different provider (or no provider). Flags
1180/// silently discarded by the wrong mode are surfaced as
1181///  BEFORE any DB work, so the operator gets
1182/// an actionable error instead of a surprise at runtime.
1183///
1184/// Mode-specific matrices:
1185/// - `mode=none` rejects: claude_binary, claude_model,
1186///   claude_timeout!=300, max_cost_usd, resume, retry_failed, keep_queue,
1187///   codex_binary, codex_model, codex_timeout!=300
1188/// - `mode=claude-code` rejects: codex_binary, codex_model, codex_timeout!=300
1189/// - `mode=codex` rejects: claude_binary, claude_model, claude_timeout!=300,
1190///   max_cost_usd, resume, retry_failed, keep_queue
1191fn validate_mode_conditional_flags_ingest(args: &IngestArgs) -> Result<(), AppError> {
1192    const DEFAULT_TIMEOUT: u64 = 300;
1193    const DEFAULT_RATE_LIMIT_WAIT: u64 = 60;
1194
1195    let mut conflicts: Vec<String> = Vec::new();
1196
1197    let is_local_mode = args.mode == IngestMode::None;
1198
1199    // v1.1.1 (P12): --name-prefix is only applied by the local staging path;
1200    // rejecting it under LLM modes avoids a silently unprefixed corpus.
1201    if args.name_prefix.is_some() && !is_local_mode {
1202        return Err(AppError::Validation(
1203            "--name-prefix is not supported with --mode claude-code/codex/opencode; \
1204             use --mode none (default)"
1205                .to_string(),
1206        ));
1207    }
1208
1209    if is_local_mode {
1210        if args.claude_binary.is_some() {
1211            conflicts.push("--claude-binary is ignored when --mode is none".to_string());
1212        }
1213        if args.claude_model.is_some() {
1214            conflicts.push("--claude-model is ignored when --mode is none".to_string());
1215        }
1216        if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1217            conflicts.push(format!(
1218                "--claude-timeout={} is ignored when --mode is none (remove the flag to use the default 300s)",
1219                args.claude_timeout
1220            ));
1221        }
1222        if args.codex_binary.is_some() {
1223            conflicts.push("--codex-binary is ignored when --mode is none".to_string());
1224        }
1225        if args.codex_model.is_some() {
1226            conflicts.push("--codex-model is ignored when --mode is none".to_string());
1227        }
1228        if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1229            conflicts.push(format!(
1230                "--codex-timeout={} is ignored when --mode is none (remove the flag to use the default 300s)",
1231                args.codex_timeout
1232            ));
1233        }
1234        if args.opencode_binary.is_some() {
1235            conflicts.push("--opencode-binary is ignored when --mode is none".to_string());
1236        }
1237        if args.opencode_model.is_some() {
1238            conflicts.push("--opencode-model is ignored when --mode is none".to_string());
1239        }
1240        if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1241            conflicts.push(format!(
1242                "--opencode-timeout={} is ignored when --mode is none (remove the flag to use the default 300s)",
1243                args.opencode_timeout
1244            ));
1245        }
1246        if args.max_cost_usd.is_some() {
1247            conflicts.push("--max-cost-usd is ignored when --mode is none (cost is only tracked for LLM-backed modes)".to_string());
1248        }
1249        if args.resume {
1250            conflicts.push("--resume is ignored when --mode is none (the queue DB is only used by LLM-backed modes)".to_string());
1251        }
1252        if args.retry_failed {
1253            conflicts.push("--retry-failed is ignored when --mode is none".to_string());
1254        }
1255        if args.keep_queue {
1256            conflicts.push("--keep-queue is ignored when --mode is none".to_string());
1257        }
1258        if !is_at_default(args.rate_limit_wait, DEFAULT_RATE_LIMIT_WAIT) {
1259            conflicts.push(format!(
1260                "--rate-limit-wait={} is ignored when --mode is none",
1261                args.rate_limit_wait
1262            ));
1263        }
1264    }
1265
1266    match args.mode {
1267        IngestMode::ClaudeCode => {
1268            if args.codex_binary.is_some() {
1269                conflicts.push("--codex-binary is ignored when --mode=claude-code".to_string());
1270            }
1271            if args.codex_model.is_some() {
1272                conflicts.push("--codex-model is ignored when --mode=claude-code".to_string());
1273            }
1274            if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1275                conflicts.push(format!(
1276                    "--codex-timeout={} is ignored when --mode=claude-code (remove the flag to use the default 300s)",
1277                    args.codex_timeout
1278                ));
1279            }
1280            if args.opencode_binary.is_some() {
1281                conflicts.push("--opencode-binary is ignored when --mode=claude-code".to_string());
1282            }
1283            if args.opencode_model.is_some() {
1284                conflicts.push("--opencode-model is ignored when --mode=claude-code".to_string());
1285            }
1286            if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1287                conflicts.push(format!(
1288                    "--opencode-timeout={} is ignored when --mode=claude-code (remove the flag to use the default 300s)",
1289                    args.opencode_timeout
1290                ));
1291            }
1292        }
1293        IngestMode::Codex => {
1294            if args.claude_binary.is_some() {
1295                conflicts.push("--claude-binary is ignored when --mode=codex".to_string());
1296            }
1297            if args.claude_model.is_some() {
1298                conflicts.push("--claude-model is ignored when --mode=codex".to_string());
1299            }
1300            if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1301                conflicts.push(format!(
1302                    "--claude-timeout={} is ignored when --mode=codex (remove the flag to use the default 300s)",
1303                    args.claude_timeout
1304                ));
1305            }
1306            if args.max_cost_usd.is_some() {
1307                conflicts.push(
1308                    "--max-cost-usd is ignored when --mode=codex (OAuth-first; cost is metered by your subscription)"
1309                        .to_string(),
1310                );
1311            }
1312            if args.resume {
1313                conflicts.push("--resume is only valid for --mode=claude-code".to_string());
1314            }
1315            if args.retry_failed {
1316                conflicts.push("--retry-failed is only valid for --mode=claude-code".to_string());
1317            }
1318            if args.keep_queue {
1319                conflicts.push("--keep-queue is only valid for --mode=claude-code".to_string());
1320            }
1321            if args.opencode_binary.is_some() {
1322                conflicts.push("--opencode-binary is ignored when --mode=codex".to_string());
1323            }
1324            if args.opencode_model.is_some() {
1325                conflicts.push("--opencode-model is ignored when --mode=codex".to_string());
1326            }
1327            if !is_at_default(args.opencode_timeout, DEFAULT_TIMEOUT) {
1328                conflicts.push(format!(
1329                    "--opencode-timeout={} is ignored when --mode=codex (remove the flag to use the default 300s)",
1330                    args.opencode_timeout
1331                ));
1332            }
1333        }
1334        IngestMode::Opencode => {
1335            if args.claude_binary.is_some() {
1336                conflicts.push("--claude-binary is ignored when --mode=opencode".to_string());
1337            }
1338            if args.claude_model.is_some() {
1339                conflicts.push("--claude-model is ignored when --mode=opencode".to_string());
1340            }
1341            if !is_at_default(args.claude_timeout, DEFAULT_TIMEOUT) {
1342                conflicts.push(format!(
1343                    "--claude-timeout={} is ignored when --mode=opencode (remove the flag to use the default 300s)",
1344                    args.claude_timeout
1345                ));
1346            }
1347            if args.codex_binary.is_some() {
1348                conflicts.push("--codex-binary is ignored when --mode=opencode".to_string());
1349            }
1350            if args.codex_model.is_some() {
1351                conflicts.push("--codex-model is ignored when --mode=opencode".to_string());
1352            }
1353            if !is_at_default(args.codex_timeout, DEFAULT_TIMEOUT) {
1354                conflicts.push(format!(
1355                    "--codex-timeout={} is ignored when --mode=opencode (remove the flag to use the default 300s)",
1356                    args.codex_timeout
1357                ));
1358            }
1359            if args.max_cost_usd.is_some() {
1360                conflicts.push(
1361                    "--max-cost-usd is ignored when --mode=opencode (OAuth-first; cost is metered by your subscription)"
1362                        .to_string(),
1363                );
1364            }
1365            if args.resume {
1366                conflicts.push("--resume is only valid for --mode=claude-code".to_string());
1367            }
1368            if args.retry_failed {
1369                conflicts.push("--retry-failed is only valid for --mode=claude-code".to_string());
1370            }
1371            if args.keep_queue {
1372                conflicts.push("--keep-queue is only valid for --mode=claude-code".to_string());
1373            }
1374        }
1375        IngestMode::None => {}
1376    }
1377
1378    if !conflicts.is_empty() {
1379        return Err(AppError::Validation(format!(
1380            "G20: mode-conditional flag conflicts detected for --mode={:?}:\n  - {}",
1381            args.mode,
1382            conflicts.join("\n  - ")
1383        )));
1384    }
1385
1386    Ok(())
1387}
1388
1389// ---------------------------------------------------------------------------
1390
1391#[tracing::instrument(skip_all, level = "debug", name = "ingest")]
1392pub fn run(
1393    args: IngestArgs,
1394    llm_backend: crate::cli::LlmBackendChoice,
1395    embedding_backend: crate::cli::EmbeddingBackendChoice,
1396) -> Result<(), AppError> {
1397    // G20: mode-conditional flag validation BEFORE any DB access.
1398    // Surfaces flags that the wrong mode would silently discard.
1399    validate_mode_conditional_flags_ingest(&args)?;
1400    tracing::debug!(target: "ingest", dir = %args.dir.display(), mode = ?args.mode, "starting ingest");
1401    if args.mode == IngestMode::ClaudeCode {
1402        return super::ingest_claude::run_claude_ingest(&args, embedding_backend, llm_backend);
1403    }
1404    if args.mode == IngestMode::Codex {
1405        return super::ingest_codex::run_codex_ingest(&args);
1406    }
1407    if args.mode == IngestMode::Opencode {
1408        return super::ingest_opencode::run_opencode_ingest(&args);
1409    }
1410
1411    let started = std::time::Instant::now();
1412
1413    if !args.dir.exists() {
1414        return Err(AppError::Validation(format!(
1415            "directory not found: {}",
1416            args.dir.display()
1417        )));
1418    }
1419    if !args.dir.is_dir() {
1420        return Err(AppError::Validation(format!(
1421            "path is not a directory: {}",
1422            args.dir.display()
1423        )));
1424    }
1425
1426    let mut files: Vec<PathBuf> = Vec::with_capacity(128);
1427    collect_files(&args.dir, &args.pattern, args.recursive, &mut files)?;
1428    files.sort_unstable();
1429
1430    if files.len() > args.max_files {
1431        return Err(AppError::Validation(format!(
1432            "found {} files matching pattern, exceeds --max-files cap of {} (raise the cap or narrow the pattern)",
1433            files.len(),
1434            args.max_files
1435        )));
1436    }
1437
1438    let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
1439    let memory_type_str = args.r#type.as_str().to_string();
1440
1441    let paths = AppPaths::resolve(args.db.as_deref())?;
1442    let mut conn_or_err = match init_storage(&paths) {
1443        Ok(c) => Ok(c),
1444        Err(e) => Err(format!("{e}")),
1445    };
1446
1447    let mut succeeded: usize = 0;
1448    let mut failed: usize = 0;
1449    let mut skipped: usize = 0;
1450    let total = files.len();
1451
1452    // Pre-resolve all names before parallelisation so Phase A workers see a
1453    // consistent, immutable name assignment (v1.0.31 A10 contract preserved).
1454    let mut taken_names: BTreeSet<String> = BTreeSet::new();
1455
1456    // SlotMeta: per-slot output metadata retained on the main thread for NDJSON.
1457    // ProcessItem: the data moved into the producer thread for Phase A computation.
1458    // We split these so `slots_meta` (non-Send BTreeSet-dependent) stays on main
1459    // thread while `process_items` (Send: only PathBuf + String) crosses the thread
1460    // boundary into the rayon producer.
1461    enum SlotMeta {
1462        Skip {
1463            file_str: String,
1464            derived_base: String,
1465            name_truncated: bool,
1466            original_name: Option<String>,
1467            original_filename: Option<String>,
1468            reason: String,
1469        },
1470        Process {
1471            file_str: String,
1472            derived_name: String,
1473            name_truncated: bool,
1474            original_name: Option<String>,
1475            original_filename: Option<String>,
1476        },
1477    }
1478
1479    struct ProcessItem {
1480        idx: usize,
1481        path: PathBuf,
1482        file_str: String,
1483        derived_name: String,
1484    }
1485
1486    let files_cap = files.len();
1487    let mut slots_meta: Vec<SlotMeta> = Vec::new();
1488    slots_meta.try_reserve(files_cap).map_err(|_| {
1489        AppError::LimitExceeded(format!(
1490            "allocation of {files_cap} slot metadata entries would exceed available memory"
1491        ))
1492    })?;
1493    let mut process_items: Vec<ProcessItem> = Vec::new();
1494    process_items.try_reserve(files_cap).map_err(|_| {
1495        AppError::LimitExceeded(format!(
1496            "allocation of {files_cap} process items would exceed available memory"
1497        ))
1498    })?;
1499    let mut truncations: Vec<(String, String)> = Vec::new();
1500    truncations.try_reserve(files_cap).map_err(|_| {
1501        AppError::LimitExceeded(format!(
1502            "allocation of {files_cap} truncation entries would exceed available memory"
1503        ))
1504    })?;
1505
1506    // v1.1.1 (P12): validate the prefix once and shrink the derived-name
1507    // budget so `prefix + derived` always fits MAX_MEMORY_NAME_LEN.
1508    let max_name_length = match args.name_prefix.as_deref() {
1509        Some(prefix) => validate_name_prefix(prefix, args.max_name_length)?,
1510        None => args.max_name_length,
1511    };
1512    for path in &files {
1513        let file_str = path.to_string_lossy().into_owned();
1514        let (derived_base, name_truncated, original_name) =
1515            derive_kebab_name(path, max_name_length);
1516        let original_basename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
1517
1518        if name_truncated {
1519            if let Some(ref orig) = original_name {
1520                truncations.push((orig.clone(), derived_base.clone()));
1521            }
1522        }
1523
1524        if derived_base.is_empty() {
1525            // original_filename: always include when it differs from the empty derived name
1526            let orig_filename = if !original_basename.is_empty() {
1527                Some(original_basename.to_string())
1528            } else {
1529                None
1530            };
1531            slots_meta.push(SlotMeta::Skip {
1532                file_str,
1533                derived_base: String::new(),
1534                name_truncated: false,
1535                original_name: None,
1536                original_filename: orig_filename,
1537                reason: "could not derive a non-empty kebab-case name from filename".to_string(),
1538            });
1539            continue;
1540        }
1541
1542        // v1.1.1 (P12): prefix applied AFTER kebab normalization of the
1543        // basename; the shrunken budget above guarantees the final length
1544        // fits MAX_MEMORY_NAME_LEN.
1545        let derived_base = match args.name_prefix.as_deref() {
1546            Some(prefix) => format!("{prefix}{derived_base}"),
1547            None => derived_base,
1548        };
1549
1550        match unique_name(&derived_base, &taken_names) {
1551            Ok(derived_name) => {
1552                taken_names.insert(derived_name.clone());
1553                let idx = slots_meta.len();
1554                // original_filename: present only when the raw basename differs from the derived name
1555                let orig_filename = if original_basename != derived_name {
1556                    Some(original_basename.to_string())
1557                } else {
1558                    None
1559                };
1560                process_items.push(ProcessItem {
1561                    idx,
1562                    path: path.clone(),
1563                    file_str: file_str.clone(),
1564                    derived_name: derived_name.clone(),
1565                });
1566                slots_meta.push(SlotMeta::Process {
1567                    file_str,
1568                    derived_name,
1569                    name_truncated,
1570                    original_name,
1571                    original_filename: orig_filename,
1572                });
1573            }
1574            Err(e) => {
1575                let orig_filename = if original_basename != derived_base {
1576                    Some(original_basename.to_string())
1577                } else {
1578                    None
1579                };
1580                slots_meta.push(SlotMeta::Skip {
1581                    file_str,
1582                    derived_base,
1583                    name_truncated,
1584                    original_name,
1585                    original_filename: orig_filename,
1586                    reason: e.to_string(),
1587                });
1588            }
1589        }
1590    }
1591
1592    if !truncations.is_empty() {
1593        tracing::info!(
1594            target: "ingest",
1595            count = truncations.len(),
1596            max_name_length = max_name_length,
1597            max_len = DERIVED_NAME_MAX_LEN,
1598            "derived names truncated; pass -vv (debug) for per-file detail"
1599        );
1600    }
1601
1602    // --dry-run: emit preview events and exit before loading ONNX or touching DB.
1603    if args.dry_run {
1604        for meta in &slots_meta {
1605            match meta {
1606                SlotMeta::Skip {
1607                    file_str,
1608                    derived_base,
1609                    name_truncated,
1610                    original_name,
1611                    original_filename,
1612                    reason,
1613                } => {
1614                    output::emit_json_compact(&IngestFileEvent {
1615                        file: file_str,
1616                        name: derived_base,
1617                        status: "skip",
1618                        truncated: *name_truncated,
1619                        original_name: original_name.clone(),
1620                        original_filename: original_filename.as_deref(),
1621                        error: Some(reason.clone()),
1622                        memory_id: None,
1623                        action: None,
1624                        body_length: 0,
1625                        backend_invoked: None,
1626                    })?;
1627                }
1628                SlotMeta::Process {
1629                    file_str,
1630                    derived_name,
1631                    name_truncated,
1632                    original_name,
1633                    original_filename,
1634                } => {
1635                    output::emit_json_compact(&IngestFileEvent {
1636                        file: file_str,
1637                        name: derived_name,
1638                        status: "preview",
1639                        truncated: *name_truncated,
1640                        original_name: original_name.clone(),
1641                        original_filename: original_filename.as_deref(),
1642                        error: None,
1643                        memory_id: None,
1644                        action: None,
1645                        body_length: 0,
1646                        backend_invoked: None,
1647                    })?;
1648
1649                    // GAP-SG-06: report chunk + token counts and how many
1650                    // sub-memories an auto-split would create, so the operator
1651                    // detects chunk/token overflow before a real ingest.
1652                    match std::fs::read_to_string(file_str) {
1653                        Ok(body) => {
1654                            let budget = chunking::assess_body_budget(&body);
1655                            output::emit_json_compact(&IngestDryRunBudget {
1656                                budget: true,
1657                                file: file_str,
1658                                name: derived_name,
1659                                bytes: budget.bytes,
1660                                chunk_count: budget.chunk_count,
1661                                token_count: budget.approx_tokens,
1662                                partition_count: budget.partition_count,
1663                                exceeds_limits: budget.exceeds_limits,
1664                            })?;
1665                        }
1666                        Err(e) => {
1667                            tracing::warn!(
1668                                target: "ingest",
1669                                file = %file_str,
1670                                "dry-run: could not read file for budget assessment: {e}"
1671                            );
1672                        }
1673                    }
1674                }
1675            }
1676        }
1677        output::emit_json_compact(&IngestSummary {
1678            summary: true,
1679            dir: args.dir.to_string_lossy().into_owned(),
1680            pattern: args.pattern.clone(),
1681            recursive: args.recursive,
1682            files_total: total,
1683            files_succeeded: 0,
1684            files_failed: 0,
1685            files_skipped: 0,
1686            elapsed_ms: started.elapsed().as_millis() as u64,
1687        })?;
1688        return Ok(());
1689    }
1690
1691    // Reject contradictory flag combination: explicit parallelism > 1 with --low-memory.
1692    if args.low_memory {
1693        if let Some(n) = args.ingest_parallelism {
1694            if n > 1 {
1695                return Err(AppError::Validation(
1696                    "--ingest-parallelism N>1 conflicts with --low-memory; use one or the other"
1697                        .to_string(),
1698                ));
1699            }
1700        }
1701    }
1702
1703    // Determine rayon thread pool size, honoring --low-memory and the
1704    // SQLITE_GRAPHRAG_LOW_MEMORY env var (both force parallelism = 1).
1705    let parallelism = resolve_parallelism(args.low_memory, args.ingest_parallelism);
1706
1707    let pool = rayon::ThreadPoolBuilder::new()
1708        .num_threads(parallelism)
1709        .build()
1710        .map_err(|e| AppError::Internal(anyhow::anyhow!("rayon pool: {e}")))?;
1711
1712    if args.enable_ner && args.skip_extraction {
1713        return Err(AppError::Validation(
1714            "--enable-ner and --skip-extraction are mutually exclusive; remove one".to_string(),
1715        ));
1716    }
1717    if args.skip_extraction && !args.enable_ner {
1718        // v1.0.74: revert to v1.0.45 hidden no-op behavior. The v1.0.67
1719        // commit (9ddb17b) promoted this to a hard validation error, which
1720        // broke the "kept as a hidden no-op for backwards compatibility"
1721        // promise documented in CHANGELOG v1.0.45 and started failing
1722        // 5+ CI jobs whose E2E tests use this flag to skip the
1723        // (since-removed) GLiNER-ONNX model download in CI environments.
1724        tracing::warn!(
1725            "--skip-extraction is deprecated since v1.0.45 and has no effect (NER is disabled by default); remove this flag to silence the warning"
1726        );
1727    }
1728    let enable_ner = args.enable_ner;
1729    let auto_describe = args.auto_describe && !args.no_auto_describe;
1730    let max_rss_mb = args.max_rss_mb;
1731    let llm_parallelism = args.llm_parallelism as usize;
1732
1733    let total_to_process = process_items.len();
1734    tracing::info!(
1735        target: "ingest",
1736        phase = "pipeline_start",
1737        files = total_to_process,
1738        ingest_parallelism = parallelism,
1739        "incremental pipeline starting: Phase A (rayon) → channel → Phase B (main thread)",
1740    );
1741
1742    // Bounded channel: producer never gets more than parallelism*2 items ahead of
1743    // the consumer, preventing memory blowup when Phase A is faster than Phase B.
1744    // Each message carries the slot index so Phase B can look up SlotMeta in order.
1745    let channel_bound = (parallelism * 2).max(1);
1746    let (tx, rx) = mpsc::sync_channel::<(usize, Result<Vec<StagedFile>, AppError>)>(channel_bound);
1747
1748    // Phase A: launched in a dedicated OS thread so the main thread can consume
1749    // the channel concurrently. pool.install() blocks the calling thread until
1750    // all rayon workers finish — if called on the main thread it would
1751    // reintroduce the 2-phase blocking behaviour we are eliminating.
1752    let paths_owned = paths.clone();
1753    let llm_backend_owned = llm_backend;
1754    let embedding_backend_owned = embedding_backend;
1755    let producer_handle = std::thread::spawn(move || {
1756        pool.install(|| {
1757            process_items.into_par_iter().for_each(|item| {
1758                if crate::shutdown_requested() {
1759                    return;
1760                }
1761                let t0 = std::time::Instant::now();
1762                let result = stage_file(
1763                    item.idx,
1764                    &item.path,
1765                    &item.derived_name,
1766                    &paths_owned,
1767                    enable_ner,
1768                    max_rss_mb,
1769                    llm_parallelism,
1770                    llm_backend_owned,
1771                    embedding_backend_owned,
1772                    auto_describe,
1773                );
1774                let elapsed_ms = t0.elapsed().as_millis() as u64;
1775
1776                // Emit NDJSON progress event to stderr so the user sees work
1777                // happening during long NER runs (e.g. 50 files × 27s each).
1778                let (n_entities, n_relationships) = match &result {
1779                    Ok(parts) => (
1780                        parts.iter().map(|sf| sf.entities.len()).sum::<usize>(),
1781                        parts.iter().map(|sf| sf.relationships.len()).sum::<usize>(),
1782                    ),
1783                    Err(_) => (0, 0),
1784                };
1785                let progress = StageProgressEvent {
1786                    schema_version: 1,
1787                    event: "file_extracted",
1788                    path: &item.file_str,
1789                    ms: elapsed_ms,
1790                    entities: n_entities,
1791                    relationships: n_relationships,
1792                };
1793                if let Ok(line) = serde_json::to_string(&progress) {
1794                    tracing::info!(target: "ingest_progress", "{}", line);
1795                }
1796
1797                // Blocking send applies backpressure: if Phase B is slower,
1798                // Phase A workers wait here instead of accumulating staged files
1799                // in memory. If the receiver is dropped (fail_fast abort), ignore.
1800                let _ = tx.send((item.idx, result));
1801            });
1802            // Explicit drop of tx signals Phase B (rx iteration) to stop.
1803            drop(tx);
1804        });
1805    });
1806
1807    // Phase B: main thread persists files as results arrive from the channel.
1808    // Results arrive in completion order (par_iter is unordered). We persist
1809    // each file immediately on arrival — this is the key fix for B1: with the
1810    // old 2-phase design the first DB write happened only after ALL files had
1811    // finished Phase A. Now the first commit happens as soon as the first file
1812    // completes Phase A, regardless of how many files remain.
1813    //
1814    // NDJSON output order follows completion order (not file-system sort order).
1815    // Skip slots are emitted at the end, after all Process results are consumed.
1816    // This trade-off is intentional: deterministic NDJSON ordering is a lesser
1817    // requirement than ensuring data is persisted before the user's timeout fires.
1818    let fail_fast = args.fail_fast;
1819
1820    // Emit pending Skip events first so agents see them early.
1821    for meta in &slots_meta {
1822        if let SlotMeta::Skip {
1823            file_str,
1824            derived_base,
1825            name_truncated,
1826            original_name,
1827            original_filename,
1828            reason,
1829        } = meta
1830        {
1831            output::emit_json_compact(&IngestFileEvent {
1832                file: file_str,
1833                name: derived_base,
1834                status: "skipped",
1835                truncated: *name_truncated,
1836                original_name: original_name.clone(),
1837                original_filename: original_filename.as_deref(),
1838                error: Some(reason.clone()),
1839                memory_id: None,
1840                action: None,
1841                body_length: 0,
1842                backend_invoked: None,
1843            })?;
1844            skipped += 1;
1845        }
1846    }
1847
1848    // Build a quick index from slot index → SlotMeta reference for O(1) lookups
1849    // as channel messages arrive in completion order.
1850    let meta_index: std::collections::HashMap<usize, &SlotMeta> = slots_meta
1851        .iter()
1852        .enumerate()
1853        .filter(|(_, m)| matches!(m, SlotMeta::Process { .. }))
1854        .collect();
1855
1856    tracing::info!(
1857        target: "ingest",
1858        phase = "persist_start",
1859        files = total_to_process,
1860        "phase B starting: persisting files incrementally as Phase A completes each one",
1861    );
1862
1863    // Drain channel and persist each file immediately — no accumulation into a
1864    // HashMap. The bounded channel ensures Phase A cannot run too far ahead of
1865    // Phase B without applying backpressure.
1866    for (idx, stage_result) in rx {
1867        if crate::shutdown_requested() {
1868            tracing::info!(target: "ingest", "shutdown requested, stopping persistence loop");
1869            break;
1870        }
1871        let meta = meta_index.get(&idx).ok_or_else(|| {
1872            AppError::Internal(anyhow::anyhow!(
1873                "channel idx {idx} has no corresponding Process slot"
1874            ))
1875        })?;
1876        let (file_str, derived_name, name_truncated, original_name, original_filename) = match meta
1877        {
1878            SlotMeta::Process {
1879                file_str,
1880                derived_name,
1881                name_truncated,
1882                original_name,
1883                original_filename,
1884            } => (
1885                file_str,
1886                derived_name,
1887                name_truncated,
1888                original_name,
1889                original_filename,
1890            ),
1891            SlotMeta::Skip { .. } => unreachable!("channel only carries Process results"),
1892        };
1893
1894        // If storage init failed, every file fails with the same error.
1895        let conn = match conn_or_err.as_mut() {
1896            Ok(c) => c,
1897            Err(err_msg) => {
1898                let err_clone = err_msg.clone();
1899                output::emit_json_compact(&IngestFileEvent {
1900                    file: file_str,
1901                    name: derived_name,
1902                    status: "failed",
1903                    truncated: *name_truncated,
1904                    original_name: original_name.clone(),
1905                    original_filename: original_filename.as_deref(),
1906                    error: Some(err_clone.clone()),
1907                    memory_id: None,
1908                    action: None,
1909                    body_length: 0,
1910                    backend_invoked: None,
1911                })?;
1912                failed += 1;
1913                if fail_fast {
1914                    output::emit_json_compact(&IngestSummary {
1915                        summary: true,
1916                        dir: args.dir.display().to_string(),
1917                        pattern: args.pattern.clone(),
1918                        recursive: args.recursive,
1919                        files_total: total,
1920                        files_succeeded: succeeded,
1921                        files_failed: failed,
1922                        files_skipped: skipped,
1923                        elapsed_ms: started.elapsed().as_millis() as u64,
1924                    })?;
1925                    return Err(AppError::Validation(format!(
1926                        "ingest aborted on first failure: {err_clone}"
1927                    )));
1928                }
1929                continue;
1930            }
1931        };
1932
1933        match stage_result {
1934            Ok(parts) => {
1935                // GAP-SG-04/07: one source file can stage as multiple
1936                // sub-memories (auto-split partitions); persist and report each.
1937                for staged in parts {
1938                    let part_name = staged.name.clone();
1939                    match persist_staged(
1940                        conn,
1941                        &namespace,
1942                        &memory_type_str,
1943                        staged,
1944                        args.force_merge,
1945                    ) {
1946                        Ok(FileSuccess {
1947                            memory_id,
1948                            action,
1949                            body_length,
1950                            backend_invoked: file_backend_invoked,
1951                        }) => {
1952                            output::emit_json_compact(&IngestFileEvent {
1953                                file: file_str,
1954                                name: &part_name,
1955                                status: "indexed",
1956                                truncated: *name_truncated,
1957                                original_name: original_name.clone(),
1958                                original_filename: original_filename.as_deref(),
1959                                error: None,
1960                                memory_id: Some(memory_id),
1961                                action: Some(action),
1962                                body_length,
1963                                backend_invoked: file_backend_invoked,
1964                            })?;
1965                            succeeded += 1;
1966                        }
1967                        Err(ref e) if matches!(e, AppError::Duplicate(_)) => {
1968                            output::emit_json_compact(&IngestFileEvent {
1969                                file: file_str,
1970                                name: &part_name,
1971                                status: "skipped",
1972                                truncated: *name_truncated,
1973                                original_name: original_name.clone(),
1974                                original_filename: original_filename.as_deref(),
1975                                error: Some(format!("{e}")),
1976                                memory_id: None,
1977                                action: Some("duplicate".to_string()),
1978                                body_length: 0,
1979                                backend_invoked: None,
1980                            })?;
1981                            skipped += 1;
1982                        }
1983                        Err(e) => {
1984                            let err_msg = format!("{e}");
1985                            output::emit_json_compact(&IngestFileEvent {
1986                                file: file_str,
1987                                name: &part_name,
1988                                status: "failed",
1989                                truncated: *name_truncated,
1990                                original_name: original_name.clone(),
1991                                original_filename: original_filename.as_deref(),
1992                                error: Some(err_msg.clone()),
1993                                memory_id: None,
1994                                action: None,
1995                                body_length: 0,
1996                                backend_invoked: None,
1997                            })?;
1998                            failed += 1;
1999                            if fail_fast {
2000                                output::emit_json_compact(&IngestSummary {
2001                                    summary: true,
2002                                    dir: args.dir.display().to_string(),
2003                                    pattern: args.pattern.clone(),
2004                                    recursive: args.recursive,
2005                                    files_total: total,
2006                                    files_succeeded: succeeded,
2007                                    files_failed: failed,
2008                                    files_skipped: skipped,
2009                                    elapsed_ms: started.elapsed().as_millis() as u64,
2010                                })?;
2011                                return Err(AppError::Validation(format!(
2012                                    "ingest aborted on first failure: {err_msg}"
2013                                )));
2014                            }
2015                        }
2016                    }
2017                }
2018            }
2019            Err(e) => {
2020                let err_msg = format!("{e}");
2021                output::emit_json_compact(&IngestFileEvent {
2022                    file: file_str,
2023                    name: derived_name,
2024                    status: "failed",
2025                    truncated: *name_truncated,
2026                    original_name: original_name.clone(),
2027                    original_filename: original_filename.as_deref(),
2028                    error: Some(err_msg.clone()),
2029                    memory_id: None,
2030                    action: None,
2031                    body_length: 0,
2032                    backend_invoked: None,
2033                })?;
2034                failed += 1;
2035                if fail_fast {
2036                    output::emit_json_compact(&IngestSummary {
2037                        summary: true,
2038                        dir: args.dir.display().to_string(),
2039                        pattern: args.pattern.clone(),
2040                        recursive: args.recursive,
2041                        files_total: total,
2042                        files_succeeded: succeeded,
2043                        files_failed: failed,
2044                        files_skipped: skipped,
2045                        elapsed_ms: started.elapsed().as_millis() as u64,
2046                    })?;
2047                    return Err(AppError::Validation(format!(
2048                        "ingest aborted on first failure: {err_msg}"
2049                    )));
2050                }
2051            }
2052        }
2053    }
2054
2055    // Wait for the producer thread to finish cleanly.
2056    producer_handle
2057        .join()
2058        .map_err(|_| AppError::Internal(anyhow::anyhow!("ingest producer thread panicked")))?;
2059
2060    if let Ok(ref conn) = conn_or_err {
2061        if succeeded > 0 {
2062            let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);");
2063        }
2064    }
2065
2066    output::emit_json_compact(&IngestSummary {
2067        summary: true,
2068        dir: args.dir.display().to_string(),
2069        pattern: args.pattern.clone(),
2070        recursive: args.recursive,
2071        files_total: total,
2072        files_succeeded: succeeded,
2073        files_failed: failed,
2074        files_skipped: skipped,
2075        elapsed_ms: started.elapsed().as_millis() as u64,
2076    })?;
2077
2078    if args.enrich_after && succeeded > 0 {
2079        output::emit_json_compact(&serde_json::json!({
2080            "event": "enrich_phase_started",
2081            "operation": "memory-bindings"
2082        }))?;
2083        let enrich_args = super::enrich::EnrichArgs {
2084            operation: Some(super::enrich::EnrichOperation::MemoryBindings),
2085            mode: Some(super::enrich::EnrichMode::ClaudeCode),
2086            limit: None,
2087            target: super::enrich::ReEmbedTarget::Memories,
2088            dry_run: false,
2089            namespace: args.namespace.clone(),
2090            claude_binary: args.claude_binary.clone(),
2091            claude_model: args.claude_model.clone(),
2092            claude_timeout: args.claude_timeout,
2093            codex_binary: args.codex_binary.clone(),
2094            codex_model: args.codex_model.clone(),
2095            codex_timeout: args.codex_timeout,
2096            opencode_binary: args.opencode_binary.clone(),
2097            opencode_model: args.opencode_model.clone(),
2098            opencode_timeout: args.opencode_timeout,
2099            openrouter_model: None,
2100            openrouter_api_key: None,
2101            openrouter_timeout: 300,
2102            openrouter_base_url: None,
2103            db: args.db.clone(),
2104            json: false,
2105            resume: false,
2106            retry_failed: false,
2107            reset_stale_claims: false,
2108            stale_claim_secs: 1800,
2109            max_cost_usd: args.max_cost_usd,
2110            llm_parallelism: args.llm_parallelism as u32,
2111            wait_job_singleton: args.wait_job_singleton,
2112            force_job_singleton: args.force_job_singleton,
2113            names: Vec::new(),
2114            names_file: None,
2115            preflight_check: false,
2116            fallback_mode: None,
2117            rate_limit_buffer: 300,
2118            max_load_check: true,
2119            circuit_breaker_threshold: 5,
2120            preserve_threshold: 0.7,
2121            codex_model_validate: true,
2122            codex_model_fallback: None,
2123            min_output_chars: 500,
2124            max_output_chars: 2000,
2125            preserve_check: true,
2126            prompt_template: None,
2127            until_empty: false,
2128            max_runtime: None,
2129            max_attempts: 5,
2130            status: false,
2131            rest_concurrency: None,
2132            // enrich-after runs a plain memory-bindings pass; dead-letter,
2133            // backoff-ignore and graph-only flags stay at their defaults.
2134            list_dead: false,
2135            requeue_dead: false,
2136            prune_dead_orphans: false,
2137            prune_dead_entity_orphans: false,
2138            ignore_backoff: false,
2139            body_extract_graph_only: false,
2140        };
2141        match super::enrich::run(&enrich_args, llm_backend, embedding_backend) {
2142            Ok(()) => {
2143                output::emit_json_compact(&serde_json::json!({
2144                    "event": "enrich_phase_completed"
2145                }))?;
2146            }
2147            Err(e) => {
2148                tracing::warn!(error = %e, "enrich --operation memory-bindings failed after ingest");
2149                output::emit_json_compact(&serde_json::json!({
2150                    "event": "enrich_phase_failed",
2151                    "error": e.to_string()
2152                }))?;
2153            }
2154        }
2155    }
2156
2157    Ok(())
2158}
2159
2160/// Auto-initialises the database (matches the contract of every other CRUD
2161/// handler) and returns a fresh read/write connection ready for the ingest
2162/// loop. Errors here are recoverable per-file: the caller surfaces them as
2163/// failure events so `--fail-fast` and the continue-on-error path keep
2164/// working when, for example, the user points `--db` at an unwritable path.
2165fn init_storage(paths: &AppPaths) -> Result<Connection, AppError> {
2166    ensure_db_ready(paths)?;
2167    let conn = open_rw(&paths.db)?;
2168    Ok(conn)
2169}
2170
2171pub(crate) fn collect_files(
2172    dir: &Path,
2173    pattern: &str,
2174    recursive: bool,
2175    out: &mut Vec<PathBuf>,
2176) -> Result<(), AppError> {
2177    let entries = std::fs::read_dir(dir).map_err(AppError::Io)?;
2178    for entry in entries {
2179        let entry = entry.map_err(AppError::Io)?;
2180        let path = entry.path();
2181        let file_type = entry.file_type().map_err(AppError::Io)?;
2182        if file_type.is_file() {
2183            let name = entry.file_name();
2184            let name_str = name.to_string_lossy();
2185            if matches_pattern(&name_str, pattern) {
2186                out.push(path);
2187            }
2188        } else if file_type.is_dir() && recursive {
2189            collect_files(&path, pattern, recursive, out)?;
2190        }
2191    }
2192    Ok(())
2193}
2194
2195fn matches_pattern(name: &str, pattern: &str) -> bool {
2196    if let Some(suffix) = pattern.strip_prefix('*') {
2197        name.ends_with(suffix)
2198    } else if let Some(prefix) = pattern.strip_suffix('*') {
2199        name.starts_with(prefix)
2200    } else {
2201        name == pattern
2202    }
2203}
2204
2205/// Returns `(final_name, truncated, original_name)`.
2206/// `truncated` is true when the derived name exceeded `max_len`.
2207/// `original_name` holds the pre-truncation name only when `truncated=true`.
2208///
2209/// Non-ASCII characters are first decomposed via NFD and then stripped of
2210/// combining marks so accented letters fold to their base ASCII letter
2211/// (e.g. `acai` from accented input, `naive` from diaeresis). Characters with no ASCII
2212/// fallback (emoji, CJK ideographs, symbols) are dropped silently. This
2213/// preserves meaningful word content rather than collapsing the basename
2214/// to a few stray ASCII letters as the previous filter did.
2215/// v1.1.1 (P12): validates `--name-prefix` and returns the effective budget
2216/// for the DERIVED part of the name, so `prefix + derived` never exceeds
2217/// [`crate::constants::MAX_MEMORY_NAME_LEN`]. The prefix is applied verbatim
2218/// AFTER kebab normalization of the basename, so it must itself be a valid
2219/// slug head: starting with a lowercase letter and containing only
2220/// lowercase letters, digits and hyphens.
2221pub(crate) fn validate_name_prefix(
2222    prefix: &str,
2223    max_name_length: usize,
2224) -> Result<usize, AppError> {
2225    if prefix.is_empty() {
2226        return Err(AppError::Validation(
2227            "--name-prefix cannot be empty".to_string(),
2228        ));
2229    }
2230    let starts_lower = prefix
2231        .chars()
2232        .next()
2233        .is_some_and(|c| c.is_ascii_lowercase());
2234    let all_slug_chars = prefix
2235        .chars()
2236        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
2237    if !starts_lower || !all_slug_chars {
2238        return Err(AppError::Validation(format!(
2239            "--name-prefix '{prefix}' must start with a lowercase letter and contain \
2240             only lowercase letters, digits and hyphens (kebab-case)"
2241        )));
2242    }
2243    let cap = crate::constants::MAX_MEMORY_NAME_LEN;
2244    if prefix.len() >= cap {
2245        return Err(AppError::LimitExceeded(format!(
2246            "--name-prefix is {} chars; prefixed names would exceed the {cap}-char \
2247             name cap (MAX_MEMORY_NAME_LEN)",
2248            prefix.len()
2249        )));
2250    }
2251    Ok(max_name_length.min(cap - prefix.len()))
2252}
2253
2254pub(crate) fn derive_kebab_name(path: &Path, max_len: usize) -> (String, bool, Option<String>) {
2255    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
2256    let lowered: String = stem
2257        .nfd()
2258        .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
2259        .map(|c| {
2260            if c == '_' || c.is_whitespace() {
2261                '-'
2262            } else {
2263                c
2264            }
2265        })
2266        .map(|c| c.to_ascii_lowercase())
2267        .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
2268        .collect();
2269    let collapsed = collapse_dashes(&lowered);
2270    let trimmed_raw = collapsed.trim_matches('-').to_string();
2271    // Prefix names that start with a digit to keep them valid kebab-case identifiers.
2272    let trimmed = if trimmed_raw.starts_with(|c: char| c.is_ascii_digit()) {
2273        format!("doc-{trimmed_raw}")
2274    } else {
2275        trimmed_raw
2276    };
2277    if trimmed.len() > max_len {
2278        let truncated = trimmed[..max_len].trim_matches('-').to_string();
2279        // GAP-SG-38: warn (not debug) so the operator sees that a derived name
2280        // was cut at the cap and that any collision will be resolved with a
2281        // numeric disambiguation suffix. The pre-truncation form is also
2282        // surfaced per-file via `IngestFileEvent.original_name`.
2283        tracing::warn!(
2284            target: "ingest",
2285            original = %trimmed,
2286            truncated_to = %truncated,
2287            max_len = max_len,
2288            "derived memory name truncated to fit length cap; collisions will be resolved with numeric suffixes"
2289        );
2290        (truncated, true, Some(trimmed))
2291    } else {
2292        (trimmed, false, None)
2293    }
2294}
2295
2296/// v1.0.31 A10: returns the first non-colliding kebab name by appending a
2297/// numeric suffix (`-1`, `-2`, …) when needed.
2298///
2299/// `taken` is the set of names already consumed in the current ingest run.
2300/// The caller is expected to insert the returned name into `taken` so the
2301/// next call observes the consumption. Cross-run collisions are intentionally
2302/// surfaced by the per-file persistence path as duplicates so re-ingestion
2303/// of identical corpora stays idempotent.
2304///
2305/// Returns `Err(AppError::Validation)` after `MAX_NAME_COLLISION_SUFFIX`
2306/// candidates collide, signalling a pathological corpus that should be
2307/// renamed manually.
2308fn unique_name(base: &str, taken: &BTreeSet<String>) -> Result<String, AppError> {
2309    if !taken.contains(base) {
2310        return Ok(base.to_string());
2311    }
2312    for suffix in 1..=MAX_NAME_COLLISION_SUFFIX {
2313        let candidate = format!("{base}-{suffix}");
2314        if !taken.contains(&candidate) {
2315            tracing::warn!(
2316                target: "ingest",
2317                base = %base,
2318                resolved = %candidate,
2319                suffix,
2320                "memory name collision resolved with numeric suffix"
2321            );
2322            return Ok(candidate);
2323        }
2324    }
2325    Err(AppError::Validation(format!(
2326        "too many name collisions for base '{base}' (>{MAX_NAME_COLLISION_SUFFIX}); rename source files to disambiguate"
2327    )))
2328}
2329
2330fn collapse_dashes(s: &str) -> String {
2331    let mut out = String::with_capacity(s.len());
2332    let mut prev_dash = false;
2333    for c in s.chars() {
2334        if c == '-' {
2335            if !prev_dash {
2336                out.push('-');
2337            }
2338            prev_dash = true;
2339        } else {
2340            out.push(c);
2341            prev_dash = false;
2342        }
2343    }
2344    out
2345}
2346
2347#[cfg(test)]
2348mod tests {
2349    use super::*;
2350    use std::path::PathBuf;
2351
2352    // v1.1.1 (P12): --name-prefix validation and budget arithmetic.
2353    #[test]
2354    fn validate_name_prefix_shrinks_budget_to_fit_name_cap() {
2355        // 80-char cap; a 10-char prefix leaves 70 for the derived part, but
2356        // the caller's budget (60) is smaller, so it wins.
2357        let budget = validate_name_prefix("projx-team", 60).unwrap();
2358        assert_eq!(budget, 60);
2359        // A long prefix shrinks the budget below the caller's 60.
2360        let long_prefix = "p".repeat(75);
2361        let budget = validate_name_prefix(&long_prefix, 60).unwrap();
2362        assert_eq!(budget, 5, "80-char cap minus 75-char prefix leaves 5");
2363    }
2364
2365    #[test]
2366    fn validate_name_prefix_rejects_invalid_slugs() {
2367        for bad in ["", "-lead", "Upper", "has_underscore", "acentuação", "1x"] {
2368            let err = validate_name_prefix(bad, 60).unwrap_err();
2369            assert_eq!(err.exit_code(), 1, "prefix '{bad}' must be Validation");
2370        }
2371    }
2372
2373    #[test]
2374    fn validate_name_prefix_too_long_is_limit_exceeded() {
2375        let huge = "p".repeat(crate::constants::MAX_MEMORY_NAME_LEN);
2376        let err = validate_name_prefix(&huge, 60).unwrap_err();
2377        assert_eq!(err.exit_code(), 6, "prefix >= name cap must be exit 6");
2378        assert!(
2379            err.to_string().contains("MAX_MEMORY_NAME_LEN"),
2380            "obtido: {err}"
2381        );
2382    }
2383
2384    #[test]
2385    fn name_prefix_applies_after_kebab_normalization_and_fits_cap() {
2386        let prefix = "projx-";
2387        let budget = validate_name_prefix(prefix, 60).unwrap();
2388        let (base, _, _) = derive_kebab_name(&PathBuf::from("My File Name.md"), budget);
2389        let final_name = format!("{prefix}{base}");
2390        assert_eq!(final_name, "projx-my-file-name");
2391        assert!(final_name.len() <= crate::constants::MAX_MEMORY_NAME_LEN);
2392        assert!(crate::constants::name_slug_regex().is_match(&final_name));
2393    }
2394
2395    /// GAP-SG-29: `ingest --mode none --resume` is rejected fail-fast by the
2396    /// mode-conditional validator, which `run()` invokes as its very first
2397    /// statement (before any DB/IO). clap 4.6 derive cannot express a
2398    /// value-conditional conflict (`--mode=none` vs `--resume`) without also
2399    /// breaking the valid `--mode claude-code --resume` combo, so the contract
2400    /// is enforced here instead of at the parser layer.
2401    #[test]
2402    fn ingest_mode_none_with_resume_is_rejected() {
2403        use crate::cli::{Cli, Commands};
2404        use clap::Parser;
2405
2406        let none_resume = Cli::try_parse_from([
2407            "sqlite-graphrag",
2408            "ingest",
2409            "./docs",
2410            "--mode",
2411            "none",
2412            "--resume",
2413        ])
2414        .expect("parse succeeds; the conflict is value-conditional");
2415        let args = match none_resume.command {
2416            Some(Commands::Ingest(a)) => a,
2417            other => panic!("expected ingest, got {other:?}"),
2418        };
2419        assert!(
2420            validate_mode_conditional_flags_ingest(&args).is_err(),
2421            "--mode none + --resume must be rejected fail-fast"
2422        );
2423
2424        // The valid LLM-mode combo is NOT rejected.
2425        let claude_resume = Cli::try_parse_from([
2426            "sqlite-graphrag",
2427            "ingest",
2428            "./docs",
2429            "--mode",
2430            "claude-code",
2431            "--resume",
2432        ])
2433        .expect("parse");
2434        let args = match claude_resume.command {
2435            Some(Commands::Ingest(a)) => a,
2436            other => panic!("expected ingest, got {other:?}"),
2437        };
2438        assert!(
2439            validate_mode_conditional_flags_ingest(&args).is_ok(),
2440            "--mode claude-code + --resume is valid and must pass"
2441        );
2442    }
2443
2444    fn setup_ingest_conn() -> Connection {
2445        crate::storage::connection::register_vec_extension();
2446        let mut conn = Connection::open_in_memory().unwrap();
2447        crate::migrations::runner().run(&mut conn).unwrap();
2448        conn
2449    }
2450
2451    fn make_staged(name: &str, body: &str) -> StagedFile {
2452        StagedFile {
2453            body: body.to_string(),
2454            body_hash: blake3::hash(body.as_bytes()).to_hex().to_string(),
2455            snippet: body.chars().take(200).collect(),
2456            name: name.to_string(),
2457            description: "desc".to_string(),
2458            embedding: None,
2459            chunk_embeddings: None,
2460            chunks_info: Vec::new(),
2461            entities: Vec::new(),
2462            relationships: Vec::new(),
2463            entity_embeddings: None,
2464            urls: Vec::new(),
2465            backend_invoked: None,
2466        }
2467    }
2468
2469    // GAP-SG-54: re-ingesting the same name without --force-merge is a duplicate
2470    // (skipped); with --force-merge it updates in place.
2471    #[test]
2472    fn persist_staged_force_merge_updates_existing() {
2473        let mut conn = setup_ingest_conn();
2474
2475        let first = persist_staged(
2476            &mut conn,
2477            "global",
2478            "document",
2479            make_staged("doc-a", "v1"),
2480            false,
2481        )
2482        .expect("create");
2483        assert_eq!(first.action, "created");
2484
2485        // Same name, no force_merge → Duplicate (skip).
2486        let dup = persist_staged(
2487            &mut conn,
2488            "global",
2489            "document",
2490            make_staged("doc-a", "v2-changed"),
2491            false,
2492        );
2493        assert!(matches!(dup, Err(AppError::Duplicate(_))));
2494
2495        // Same name, force_merge → updated, body refreshed.
2496        let upd = persist_staged(
2497            &mut conn,
2498            "global",
2499            "document",
2500            make_staged("doc-a", "v2-changed"),
2501            true,
2502        )
2503        .expect("update");
2504        assert_eq!(upd.action, "updated");
2505        assert_eq!(upd.memory_id, first.memory_id);
2506        let body: String = conn
2507            .query_row(
2508                "SELECT body FROM memories WHERE id = ?1",
2509                rusqlite::params![first.memory_id],
2510                |r| r.get(0),
2511            )
2512            .unwrap();
2513        assert_eq!(body, "v2-changed");
2514    }
2515
2516    // GAP-SG-55: identical body under a divergent name is deduped (skipped).
2517    #[test]
2518    fn persist_staged_dedupes_by_body_hash() {
2519        let mut conn = setup_ingest_conn();
2520        persist_staged(
2521            &mut conn,
2522            "global",
2523            "document",
2524            make_staged("parte-1", "identical content"),
2525            false,
2526        )
2527        .expect("create");
2528
2529        // Divergent derived name, same content → skipped as duplicate.
2530        let res = persist_staged(
2531            &mut conn,
2532            "global",
2533            "document",
2534            make_staged("part-01", "identical content"),
2535            false,
2536        );
2537        match res {
2538            Err(AppError::Duplicate(msg)) => assert!(msg.contains("body_hash")),
2539            other => panic!("expected body_hash dedup duplicate, got {other:?}"),
2540        }
2541        // Only one memory persisted.
2542        let n: i64 = conn
2543            .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
2544            .unwrap();
2545        assert_eq!(n, 1);
2546    }
2547
2548    // GAP-SG-54: `ingest --force-merge` parses and sets the update flag.
2549    #[test]
2550    fn ingest_force_merge_flag_parses() {
2551        use crate::cli::{Cli, Commands};
2552        use clap::Parser;
2553        let cli = Cli::try_parse_from(["sqlite-graphrag", "ingest", "./docs", "--force-merge"])
2554            .expect("parse");
2555        match cli.command {
2556            Some(Commands::Ingest(a)) => assert!(a.force_merge),
2557            other => panic!("expected ingest, got {other:?}"),
2558        }
2559        // Default is off.
2560        let cli2 = Cli::try_parse_from(["sqlite-graphrag", "ingest", "./docs"]).expect("parse");
2561        match cli2.command {
2562            Some(Commands::Ingest(a)) => assert!(!a.force_merge),
2563            other => panic!("expected ingest, got {other:?}"),
2564        }
2565    }
2566
2567    #[test]
2568    fn matches_pattern_suffix() {
2569        assert!(matches_pattern("foo.md", "*.md"));
2570        assert!(!matches_pattern("foo.txt", "*.md"));
2571        assert!(matches_pattern("foo.md", "*"));
2572    }
2573
2574    #[test]
2575    fn matches_pattern_prefix() {
2576        assert!(matches_pattern("README.md", "README*"));
2577        assert!(!matches_pattern("CHANGELOG.md", "README*"));
2578    }
2579
2580    #[test]
2581    fn matches_pattern_exact() {
2582        assert!(matches_pattern("README.md", "README.md"));
2583        assert!(!matches_pattern("readme.md", "README.md"));
2584    }
2585
2586    #[test]
2587    fn derive_kebab_underscore_to_dash() {
2588        let p = PathBuf::from("/tmp/claude_code_headless.md");
2589        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2590        assert_eq!(name, "claude-code-headless");
2591        assert!(!truncated);
2592        assert!(original.is_none());
2593    }
2594
2595    #[test]
2596    fn derive_kebab_uppercase_lowered() {
2597        let p = PathBuf::from("/tmp/README.md");
2598        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2599        assert_eq!(name, "readme");
2600        assert!(!truncated);
2601        assert!(original.is_none());
2602    }
2603
2604    #[test]
2605    fn derive_kebab_strips_non_kebab_chars() {
2606        let p = PathBuf::from("/tmp/some@weird#name!.md");
2607        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2608        assert_eq!(name, "someweirdname");
2609        assert!(!truncated);
2610        assert!(original.is_none());
2611    }
2612
2613    // Bug M-A3: NFD-based unicode normalization preserves base letters of
2614    // accented characters instead of dropping them entirely.
2615    #[test]
2616    fn derive_kebab_folds_accented_letters_to_ascii() {
2617        let p = PathBuf::from("/tmp/açaí.md");
2618        let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2619        assert_eq!(name, "acai", "got '{name}'");
2620    }
2621
2622    #[test]
2623    fn derive_kebab_handles_naive_with_diaeresis() {
2624        let p = PathBuf::from("/tmp/naïve-test.md");
2625        let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2626        assert_eq!(name, "naive-test", "got '{name}'");
2627    }
2628
2629    #[test]
2630    fn derive_kebab_drops_emoji_keeps_word() {
2631        let p = PathBuf::from("/tmp/🚀-rocket.md");
2632        let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2633        assert_eq!(name, "rocket", "got '{name}'");
2634    }
2635
2636    #[test]
2637    fn derive_kebab_mixed_unicode_emoji_keeps_letters() {
2638        let p = PathBuf::from("/tmp/açaí🦜.md");
2639        let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2640        assert_eq!(name, "acai", "got '{name}'");
2641    }
2642
2643    #[test]
2644    fn derive_kebab_pure_emoji_yields_empty() {
2645        let p = PathBuf::from("/tmp/🦜🚀🌟.md");
2646        let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2647        assert!(name.is_empty(), "got '{name}'");
2648    }
2649
2650    #[test]
2651    fn derive_kebab_collapses_consecutive_dashes() {
2652        let p = PathBuf::from("/tmp/a__b___c.md");
2653        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2654        assert_eq!(name, "a-b-c");
2655        assert!(!truncated);
2656        assert!(original.is_none());
2657    }
2658
2659    #[test]
2660    fn derive_kebab_truncates_to_60_chars() {
2661        let p = PathBuf::from(format!("/tmp/{}.md", "a".repeat(80)));
2662        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2663        assert!(name.len() <= 60, "got len {}", name.len());
2664        assert!(truncated);
2665        assert!(original.is_some());
2666        assert!(original.unwrap().len() > 60);
2667    }
2668
2669    #[test]
2670    fn collect_files_finds_md_files() {
2671        let tmp = tempfile::tempdir().expect("tempdir");
2672        std::fs::write(tmp.path().join("a.md"), "x").unwrap();
2673        std::fs::write(tmp.path().join("b.md"), "y").unwrap();
2674        std::fs::write(tmp.path().join("c.txt"), "z").unwrap();
2675        let mut out = Vec::new();
2676        collect_files(tmp.path(), "*.md", false, &mut out).expect("collect");
2677        assert_eq!(out.len(), 2, "should find 2 .md files, got {out:?}");
2678    }
2679
2680    #[test]
2681    fn collect_files_recursive_descends_subdirs() {
2682        let tmp = tempfile::tempdir().expect("tempdir");
2683        let sub = tmp.path().join("sub");
2684        std::fs::create_dir(&sub).unwrap();
2685        std::fs::write(tmp.path().join("a.md"), "x").unwrap();
2686        std::fs::write(sub.join("b.md"), "y").unwrap();
2687        let mut out = Vec::new();
2688        collect_files(tmp.path(), "*.md", true, &mut out).expect("collect");
2689        assert_eq!(out.len(), 2);
2690    }
2691
2692    #[test]
2693    fn collect_files_non_recursive_skips_subdirs() {
2694        let tmp = tempfile::tempdir().expect("tempdir");
2695        let sub = tmp.path().join("sub");
2696        std::fs::create_dir(&sub).unwrap();
2697        std::fs::write(tmp.path().join("a.md"), "x").unwrap();
2698        std::fs::write(sub.join("b.md"), "y").unwrap();
2699        let mut out = Vec::new();
2700        collect_files(tmp.path(), "*.md", false, &mut out).expect("collect");
2701        assert_eq!(out.len(), 1);
2702    }
2703
2704    // ── v1.0.31 A10: name truncation warns and collisions are auto-resolved ──
2705
2706    #[test]
2707    fn derive_kebab_long_basename_truncated_within_cap() {
2708        let p = PathBuf::from(format!("/tmp/{}.md", "a".repeat(120)));
2709        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2710        assert!(
2711            name.len() <= DERIVED_NAME_MAX_LEN,
2712            "truncated name must respect cap; got {} chars",
2713            name.len()
2714        );
2715        assert!(!name.is_empty());
2716        assert!(truncated);
2717        assert!(original.is_some());
2718    }
2719
2720    #[test]
2721    fn unique_name_returns_base_when_free() {
2722        let taken: BTreeSet<String> = BTreeSet::new();
2723        let resolved = unique_name("note", &taken).expect("must resolve");
2724        assert_eq!(resolved, "note");
2725    }
2726
2727    #[test]
2728    fn unique_name_appends_first_free_suffix_on_collision() {
2729        let mut taken: BTreeSet<String> = BTreeSet::new();
2730        taken.insert("note".to_string());
2731        taken.insert("note-1".to_string());
2732        let resolved = unique_name("note", &taken).expect("must resolve");
2733        assert_eq!(resolved, "note-2");
2734    }
2735
2736    #[test]
2737    fn unique_name_errors_after_collision_cap() {
2738        let mut taken: BTreeSet<String> = BTreeSet::new();
2739        taken.insert("note".to_string());
2740        for i in 1..=MAX_NAME_COLLISION_SUFFIX {
2741            taken.insert(format!("note-{i}"));
2742        }
2743        let err = unique_name("note", &taken).expect_err("must surface error");
2744        assert!(matches!(err, AppError::Validation(_)));
2745    }
2746
2747    // ── v1.0.32 Onda 4B: in-process pipeline validation ──
2748
2749    #[test]
2750    fn validate_relation_format_accepts_valid_relations() {
2751        use crate::parsers::{is_canonical_relation, validate_relation_format};
2752        assert!(validate_relation_format("applies_to").is_ok());
2753        assert!(validate_relation_format("depends_on").is_ok());
2754        assert!(validate_relation_format("implements").is_ok());
2755        assert!(validate_relation_format("").is_err());
2756        assert!(is_canonical_relation("applies_to"));
2757        assert!(!is_canonical_relation("implements"));
2758    }
2759
2760    // ── v1.0.40 H-A1: --low-memory flag and SQLITE_GRAPHRAG_LOW_MEMORY env var ──
2761
2762    use serial_test::serial;
2763
2764    /// Helper: scrubs the env var around a closure to keep tests deterministic.
2765    fn with_env_var<F: FnOnce()>(value: Option<&str>, f: F) {
2766        let key = "SQLITE_GRAPHRAG_LOW_MEMORY";
2767        let prev = std::env::var(key).ok();
2768        match value {
2769            Some(v) => std::env::set_var(key, v),
2770            None => std::env::remove_var(key),
2771        }
2772        f();
2773        match prev {
2774            Some(p) => std::env::set_var(key, p),
2775            None => std::env::remove_var(key),
2776        }
2777    }
2778
2779    #[test]
2780    #[serial]
2781    fn env_low_memory_enabled_unset_returns_false() {
2782        with_env_var(None, || assert!(!env_low_memory_enabled()));
2783    }
2784
2785    #[test]
2786    #[serial]
2787    fn env_low_memory_enabled_empty_returns_false() {
2788        with_env_var(Some(""), || assert!(!env_low_memory_enabled()));
2789    }
2790
2791    #[test]
2792    #[serial]
2793    fn env_low_memory_enabled_truthy_values_return_true() {
2794        for v in ["1", "true", "TRUE", "yes", "YES", "on", "On"] {
2795            with_env_var(Some(v), || {
2796                assert!(env_low_memory_enabled(), "value {v:?} should be truthy")
2797            });
2798        }
2799    }
2800
2801    #[test]
2802    #[serial]
2803    fn env_low_memory_enabled_falsy_values_return_false() {
2804        for v in ["0", "false", "FALSE", "no", "off"] {
2805            with_env_var(Some(v), || {
2806                assert!(!env_low_memory_enabled(), "value {v:?} should be falsy")
2807            });
2808        }
2809    }
2810
2811    #[test]
2812    #[serial]
2813    fn env_low_memory_enabled_unrecognized_value_returns_false() {
2814        with_env_var(Some("maybe"), || assert!(!env_low_memory_enabled()));
2815    }
2816
2817    #[test]
2818    #[serial]
2819    fn resolve_parallelism_flag_forces_one_overriding_explicit_value() {
2820        with_env_var(None, || {
2821            assert_eq!(resolve_parallelism(true, Some(4)), 1);
2822            assert_eq!(resolve_parallelism(true, Some(8)), 1);
2823            assert_eq!(resolve_parallelism(true, None), 1);
2824        });
2825    }
2826
2827    #[test]
2828    #[serial]
2829    fn resolve_parallelism_env_forces_one_when_flag_off() {
2830        with_env_var(Some("1"), || {
2831            assert_eq!(resolve_parallelism(false, Some(4)), 1);
2832            assert_eq!(resolve_parallelism(false, None), 1);
2833        });
2834    }
2835
2836    #[test]
2837    #[serial]
2838    fn resolve_parallelism_falsy_env_does_not_override() {
2839        with_env_var(Some("0"), || {
2840            assert_eq!(resolve_parallelism(false, Some(4)), 4);
2841        });
2842    }
2843
2844    #[test]
2845    #[serial]
2846    fn resolve_parallelism_explicit_value_when_low_memory_off() {
2847        with_env_var(None, || {
2848            assert_eq!(resolve_parallelism(false, Some(3)), 3);
2849            assert_eq!(resolve_parallelism(false, Some(1)), 1);
2850        });
2851    }
2852
2853    #[test]
2854    #[serial]
2855    fn resolve_parallelism_default_when_unset() {
2856        with_env_var(None, || {
2857            let p = resolve_parallelism(false, None);
2858            assert!((1..=4).contains(&p), "default must be in [1, 4]; got {p}");
2859        });
2860    }
2861
2862    #[test]
2863    fn ingest_args_parses_low_memory_flag_via_clap() {
2864        use clap::Parser;
2865        // Parse a synthetic Cli that contains the `ingest` subcommand. We rely
2866        // on the public `Cli` definition so the flag is wired end-to-end.
2867        let cli = crate::cli::Cli::try_parse_from([
2868            "sqlite-graphrag",
2869            "ingest",
2870            "/tmp/dummy",
2871            "--type",
2872            "document",
2873            "--low-memory",
2874        ])
2875        .expect("parse must succeed");
2876        match cli.command {
2877            Some(crate::cli::Commands::Ingest(args)) => {
2878                assert!(args.low_memory, "--low-memory must set field to true");
2879            }
2880            _ => panic!("expected Ingest subcommand"),
2881        }
2882    }
2883
2884    #[test]
2885    fn ingest_args_low_memory_defaults_false() {
2886        use clap::Parser;
2887        let cli = crate::cli::Cli::try_parse_from([
2888            "sqlite-graphrag",
2889            "ingest",
2890            "/tmp/dummy",
2891            "--type",
2892            "document",
2893        ])
2894        .expect("parse must succeed");
2895        match cli.command {
2896            Some(crate::cli::Commands::Ingest(args)) => {
2897                assert!(!args.low_memory, "default must be false");
2898            }
2899            _ => panic!("expected Ingest subcommand"),
2900        }
2901    }
2902
2903    // ── GAP-SG-06: --dry-run reports chunk and token counts ──
2904
2905    #[test]
2906    fn dry_run_budget_event_serializes_chunk_and_token_counts() {
2907        let ev = IngestDryRunBudget {
2908            budget: true,
2909            file: "/tmp/doc.md",
2910            name: "doc",
2911            bytes: 1234,
2912            chunk_count: 3,
2913            token_count: 567,
2914            partition_count: 1,
2915            exceeds_limits: false,
2916        };
2917        let json = serde_json::to_string(&ev).expect("serialize budget event");
2918        assert!(json.contains("\"chunk_count\":3"), "got: {json}");
2919        assert!(json.contains("\"token_count\":567"), "got: {json}");
2920        assert!(json.contains("\"partition_count\":1"), "got: {json}");
2921        assert!(json.contains("\"exceeds_limits\":false"), "got: {json}");
2922    }
2923
2924    #[test]
2925    fn assess_body_budget_feeds_dry_run_with_positive_counts() {
2926        // The dry-run path feeds chunking::assess_body_budget; a representative
2927        // body must report a positive chunk and token count.
2928        let body = "# Title\n\nsome representative body text for the budget.";
2929        let budget = chunking::assess_body_budget(body);
2930        assert!(budget.chunk_count >= 1);
2931        assert!(budget.approx_tokens >= 1);
2932        assert_eq!(budget.partition_count, 1);
2933    }
2934}