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            max_cost_usd: args.max_cost_usd,
2108            llm_parallelism: args.llm_parallelism as u32,
2109            wait_job_singleton: args.wait_job_singleton,
2110            force_job_singleton: args.force_job_singleton,
2111            names: Vec::new(),
2112            names_file: None,
2113            preflight_check: false,
2114            fallback_mode: None,
2115            rate_limit_buffer: 300,
2116            max_load_check: true,
2117            circuit_breaker_threshold: 5,
2118            preserve_threshold: 0.7,
2119            codex_model_validate: true,
2120            codex_model_fallback: None,
2121            min_output_chars: 500,
2122            max_output_chars: 2000,
2123            preserve_check: true,
2124            prompt_template: None,
2125            until_empty: false,
2126            max_runtime: None,
2127            max_attempts: 5,
2128            status: false,
2129            rest_concurrency: None,
2130            // enrich-after runs a plain memory-bindings pass; dead-letter,
2131            // backoff-ignore and graph-only flags stay at their defaults.
2132            list_dead: false,
2133            requeue_dead: false,
2134            prune_dead_orphans: false,
2135            prune_dead_entity_orphans: false,
2136            ignore_backoff: false,
2137            body_extract_graph_only: false,
2138        };
2139        match super::enrich::run(&enrich_args, llm_backend, embedding_backend) {
2140            Ok(()) => {
2141                output::emit_json_compact(&serde_json::json!({
2142                    "event": "enrich_phase_completed"
2143                }))?;
2144            }
2145            Err(e) => {
2146                tracing::warn!(error = %e, "enrich --operation memory-bindings failed after ingest");
2147                output::emit_json_compact(&serde_json::json!({
2148                    "event": "enrich_phase_failed",
2149                    "error": e.to_string()
2150                }))?;
2151            }
2152        }
2153    }
2154
2155    Ok(())
2156}
2157
2158/// Auto-initialises the database (matches the contract of every other CRUD
2159/// handler) and returns a fresh read/write connection ready for the ingest
2160/// loop. Errors here are recoverable per-file: the caller surfaces them as
2161/// failure events so `--fail-fast` and the continue-on-error path keep
2162/// working when, for example, the user points `--db` at an unwritable path.
2163fn init_storage(paths: &AppPaths) -> Result<Connection, AppError> {
2164    ensure_db_ready(paths)?;
2165    let conn = open_rw(&paths.db)?;
2166    Ok(conn)
2167}
2168
2169pub(crate) fn collect_files(
2170    dir: &Path,
2171    pattern: &str,
2172    recursive: bool,
2173    out: &mut Vec<PathBuf>,
2174) -> Result<(), AppError> {
2175    let entries = std::fs::read_dir(dir).map_err(AppError::Io)?;
2176    for entry in entries {
2177        let entry = entry.map_err(AppError::Io)?;
2178        let path = entry.path();
2179        let file_type = entry.file_type().map_err(AppError::Io)?;
2180        if file_type.is_file() {
2181            let name = entry.file_name();
2182            let name_str = name.to_string_lossy();
2183            if matches_pattern(&name_str, pattern) {
2184                out.push(path);
2185            }
2186        } else if file_type.is_dir() && recursive {
2187            collect_files(&path, pattern, recursive, out)?;
2188        }
2189    }
2190    Ok(())
2191}
2192
2193fn matches_pattern(name: &str, pattern: &str) -> bool {
2194    if let Some(suffix) = pattern.strip_prefix('*') {
2195        name.ends_with(suffix)
2196    } else if let Some(prefix) = pattern.strip_suffix('*') {
2197        name.starts_with(prefix)
2198    } else {
2199        name == pattern
2200    }
2201}
2202
2203/// Returns `(final_name, truncated, original_name)`.
2204/// `truncated` is true when the derived name exceeded `max_len`.
2205/// `original_name` holds the pre-truncation name only when `truncated=true`.
2206///
2207/// Non-ASCII characters are first decomposed via NFD and then stripped of
2208/// combining marks so accented letters fold to their base ASCII letter
2209/// (e.g. `acai` from accented input, `naive` from diaeresis). Characters with no ASCII
2210/// fallback (emoji, CJK ideographs, symbols) are dropped silently. This
2211/// preserves meaningful word content rather than collapsing the basename
2212/// to a few stray ASCII letters as the previous filter did.
2213/// v1.1.1 (P12): validates `--name-prefix` and returns the effective budget
2214/// for the DERIVED part of the name, so `prefix + derived` never exceeds
2215/// [`crate::constants::MAX_MEMORY_NAME_LEN`]. The prefix is applied verbatim
2216/// AFTER kebab normalization of the basename, so it must itself be a valid
2217/// slug head: starting with a lowercase letter and containing only
2218/// lowercase letters, digits and hyphens.
2219pub(crate) fn validate_name_prefix(
2220    prefix: &str,
2221    max_name_length: usize,
2222) -> Result<usize, AppError> {
2223    if prefix.is_empty() {
2224        return Err(AppError::Validation(
2225            "--name-prefix cannot be empty".to_string(),
2226        ));
2227    }
2228    let starts_lower = prefix
2229        .chars()
2230        .next()
2231        .is_some_and(|c| c.is_ascii_lowercase());
2232    let all_slug_chars = prefix
2233        .chars()
2234        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
2235    if !starts_lower || !all_slug_chars {
2236        return Err(AppError::Validation(format!(
2237            "--name-prefix '{prefix}' must start with a lowercase letter and contain \
2238             only lowercase letters, digits and hyphens (kebab-case)"
2239        )));
2240    }
2241    let cap = crate::constants::MAX_MEMORY_NAME_LEN;
2242    if prefix.len() >= cap {
2243        return Err(AppError::LimitExceeded(format!(
2244            "--name-prefix is {} chars; prefixed names would exceed the {cap}-char \
2245             name cap (MAX_MEMORY_NAME_LEN)",
2246            prefix.len()
2247        )));
2248    }
2249    Ok(max_name_length.min(cap - prefix.len()))
2250}
2251
2252pub(crate) fn derive_kebab_name(path: &Path, max_len: usize) -> (String, bool, Option<String>) {
2253    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
2254    let lowered: String = stem
2255        .nfd()
2256        .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
2257        .map(|c| {
2258            if c == '_' || c.is_whitespace() {
2259                '-'
2260            } else {
2261                c
2262            }
2263        })
2264        .map(|c| c.to_ascii_lowercase())
2265        .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || *c == '-')
2266        .collect();
2267    let collapsed = collapse_dashes(&lowered);
2268    let trimmed_raw = collapsed.trim_matches('-').to_string();
2269    // Prefix names that start with a digit to keep them valid kebab-case identifiers.
2270    let trimmed = if trimmed_raw.starts_with(|c: char| c.is_ascii_digit()) {
2271        format!("doc-{trimmed_raw}")
2272    } else {
2273        trimmed_raw
2274    };
2275    if trimmed.len() > max_len {
2276        let truncated = trimmed[..max_len].trim_matches('-').to_string();
2277        // GAP-SG-38: warn (not debug) so the operator sees that a derived name
2278        // was cut at the cap and that any collision will be resolved with a
2279        // numeric disambiguation suffix. The pre-truncation form is also
2280        // surfaced per-file via `IngestFileEvent.original_name`.
2281        tracing::warn!(
2282            target: "ingest",
2283            original = %trimmed,
2284            truncated_to = %truncated,
2285            max_len = max_len,
2286            "derived memory name truncated to fit length cap; collisions will be resolved with numeric suffixes"
2287        );
2288        (truncated, true, Some(trimmed))
2289    } else {
2290        (trimmed, false, None)
2291    }
2292}
2293
2294/// v1.0.31 A10: returns the first non-colliding kebab name by appending a
2295/// numeric suffix (`-1`, `-2`, …) when needed.
2296///
2297/// `taken` is the set of names already consumed in the current ingest run.
2298/// The caller is expected to insert the returned name into `taken` so the
2299/// next call observes the consumption. Cross-run collisions are intentionally
2300/// surfaced by the per-file persistence path as duplicates so re-ingestion
2301/// of identical corpora stays idempotent.
2302///
2303/// Returns `Err(AppError::Validation)` after `MAX_NAME_COLLISION_SUFFIX`
2304/// candidates collide, signalling a pathological corpus that should be
2305/// renamed manually.
2306fn unique_name(base: &str, taken: &BTreeSet<String>) -> Result<String, AppError> {
2307    if !taken.contains(base) {
2308        return Ok(base.to_string());
2309    }
2310    for suffix in 1..=MAX_NAME_COLLISION_SUFFIX {
2311        let candidate = format!("{base}-{suffix}");
2312        if !taken.contains(&candidate) {
2313            tracing::warn!(
2314                target: "ingest",
2315                base = %base,
2316                resolved = %candidate,
2317                suffix,
2318                "memory name collision resolved with numeric suffix"
2319            );
2320            return Ok(candidate);
2321        }
2322    }
2323    Err(AppError::Validation(format!(
2324        "too many name collisions for base '{base}' (>{MAX_NAME_COLLISION_SUFFIX}); rename source files to disambiguate"
2325    )))
2326}
2327
2328fn collapse_dashes(s: &str) -> String {
2329    let mut out = String::with_capacity(s.len());
2330    let mut prev_dash = false;
2331    for c in s.chars() {
2332        if c == '-' {
2333            if !prev_dash {
2334                out.push('-');
2335            }
2336            prev_dash = true;
2337        } else {
2338            out.push(c);
2339            prev_dash = false;
2340        }
2341    }
2342    out
2343}
2344
2345#[cfg(test)]
2346mod tests {
2347    use super::*;
2348    use std::path::PathBuf;
2349
2350    // v1.1.1 (P12): --name-prefix validation and budget arithmetic.
2351    #[test]
2352    fn validate_name_prefix_shrinks_budget_to_fit_name_cap() {
2353        // 80-char cap; a 10-char prefix leaves 70 for the derived part, but
2354        // the caller's budget (60) is smaller, so it wins.
2355        let budget = validate_name_prefix("projx-team", 60).unwrap();
2356        assert_eq!(budget, 60);
2357        // A long prefix shrinks the budget below the caller's 60.
2358        let long_prefix = "p".repeat(75);
2359        let budget = validate_name_prefix(&long_prefix, 60).unwrap();
2360        assert_eq!(budget, 5, "80-char cap minus 75-char prefix leaves 5");
2361    }
2362
2363    #[test]
2364    fn validate_name_prefix_rejects_invalid_slugs() {
2365        for bad in ["", "-lead", "Upper", "has_underscore", "acentuação", "1x"] {
2366            let err = validate_name_prefix(bad, 60).unwrap_err();
2367            assert_eq!(err.exit_code(), 1, "prefix '{bad}' must be Validation");
2368        }
2369    }
2370
2371    #[test]
2372    fn validate_name_prefix_too_long_is_limit_exceeded() {
2373        let huge = "p".repeat(crate::constants::MAX_MEMORY_NAME_LEN);
2374        let err = validate_name_prefix(&huge, 60).unwrap_err();
2375        assert_eq!(err.exit_code(), 6, "prefix >= name cap must be exit 6");
2376        assert!(
2377            err.to_string().contains("MAX_MEMORY_NAME_LEN"),
2378            "obtido: {err}"
2379        );
2380    }
2381
2382    #[test]
2383    fn name_prefix_applies_after_kebab_normalization_and_fits_cap() {
2384        let prefix = "projx-";
2385        let budget = validate_name_prefix(prefix, 60).unwrap();
2386        let (base, _, _) = derive_kebab_name(&PathBuf::from("My File Name.md"), budget);
2387        let final_name = format!("{prefix}{base}");
2388        assert_eq!(final_name, "projx-my-file-name");
2389        assert!(final_name.len() <= crate::constants::MAX_MEMORY_NAME_LEN);
2390        assert!(crate::constants::name_slug_regex().is_match(&final_name));
2391    }
2392
2393    /// GAP-SG-29: `ingest --mode none --resume` is rejected fail-fast by the
2394    /// mode-conditional validator, which `run()` invokes as its very first
2395    /// statement (before any DB/IO). clap 4.6 derive cannot express a
2396    /// value-conditional conflict (`--mode=none` vs `--resume`) without also
2397    /// breaking the valid `--mode claude-code --resume` combo, so the contract
2398    /// is enforced here instead of at the parser layer.
2399    #[test]
2400    fn ingest_mode_none_with_resume_is_rejected() {
2401        use crate::cli::{Cli, Commands};
2402        use clap::Parser;
2403
2404        let none_resume = Cli::try_parse_from([
2405            "sqlite-graphrag",
2406            "ingest",
2407            "./docs",
2408            "--mode",
2409            "none",
2410            "--resume",
2411        ])
2412        .expect("parse succeeds; the conflict is value-conditional");
2413        let args = match none_resume.command {
2414            Some(Commands::Ingest(a)) => a,
2415            other => panic!("expected ingest, got {other:?}"),
2416        };
2417        assert!(
2418            validate_mode_conditional_flags_ingest(&args).is_err(),
2419            "--mode none + --resume must be rejected fail-fast"
2420        );
2421
2422        // The valid LLM-mode combo is NOT rejected.
2423        let claude_resume = Cli::try_parse_from([
2424            "sqlite-graphrag",
2425            "ingest",
2426            "./docs",
2427            "--mode",
2428            "claude-code",
2429            "--resume",
2430        ])
2431        .expect("parse");
2432        let args = match claude_resume.command {
2433            Some(Commands::Ingest(a)) => a,
2434            other => panic!("expected ingest, got {other:?}"),
2435        };
2436        assert!(
2437            validate_mode_conditional_flags_ingest(&args).is_ok(),
2438            "--mode claude-code + --resume is valid and must pass"
2439        );
2440    }
2441
2442    fn setup_ingest_conn() -> Connection {
2443        crate::storage::connection::register_vec_extension();
2444        let mut conn = Connection::open_in_memory().unwrap();
2445        crate::migrations::runner().run(&mut conn).unwrap();
2446        conn
2447    }
2448
2449    fn make_staged(name: &str, body: &str) -> StagedFile {
2450        StagedFile {
2451            body: body.to_string(),
2452            body_hash: blake3::hash(body.as_bytes()).to_hex().to_string(),
2453            snippet: body.chars().take(200).collect(),
2454            name: name.to_string(),
2455            description: "desc".to_string(),
2456            embedding: None,
2457            chunk_embeddings: None,
2458            chunks_info: Vec::new(),
2459            entities: Vec::new(),
2460            relationships: Vec::new(),
2461            entity_embeddings: None,
2462            urls: Vec::new(),
2463            backend_invoked: None,
2464        }
2465    }
2466
2467    // GAP-SG-54: re-ingesting the same name without --force-merge is a duplicate
2468    // (skipped); with --force-merge it updates in place.
2469    #[test]
2470    fn persist_staged_force_merge_updates_existing() {
2471        let mut conn = setup_ingest_conn();
2472
2473        let first = persist_staged(
2474            &mut conn,
2475            "global",
2476            "document",
2477            make_staged("doc-a", "v1"),
2478            false,
2479        )
2480        .expect("create");
2481        assert_eq!(first.action, "created");
2482
2483        // Same name, no force_merge → Duplicate (skip).
2484        let dup = persist_staged(
2485            &mut conn,
2486            "global",
2487            "document",
2488            make_staged("doc-a", "v2-changed"),
2489            false,
2490        );
2491        assert!(matches!(dup, Err(AppError::Duplicate(_))));
2492
2493        // Same name, force_merge → updated, body refreshed.
2494        let upd = persist_staged(
2495            &mut conn,
2496            "global",
2497            "document",
2498            make_staged("doc-a", "v2-changed"),
2499            true,
2500        )
2501        .expect("update");
2502        assert_eq!(upd.action, "updated");
2503        assert_eq!(upd.memory_id, first.memory_id);
2504        let body: String = conn
2505            .query_row(
2506                "SELECT body FROM memories WHERE id = ?1",
2507                rusqlite::params![first.memory_id],
2508                |r| r.get(0),
2509            )
2510            .unwrap();
2511        assert_eq!(body, "v2-changed");
2512    }
2513
2514    // GAP-SG-55: identical body under a divergent name is deduped (skipped).
2515    #[test]
2516    fn persist_staged_dedupes_by_body_hash() {
2517        let mut conn = setup_ingest_conn();
2518        persist_staged(
2519            &mut conn,
2520            "global",
2521            "document",
2522            make_staged("parte-1", "identical content"),
2523            false,
2524        )
2525        .expect("create");
2526
2527        // Divergent derived name, same content → skipped as duplicate.
2528        let res = persist_staged(
2529            &mut conn,
2530            "global",
2531            "document",
2532            make_staged("part-01", "identical content"),
2533            false,
2534        );
2535        match res {
2536            Err(AppError::Duplicate(msg)) => assert!(msg.contains("body_hash")),
2537            other => panic!("expected body_hash dedup duplicate, got {other:?}"),
2538        }
2539        // Only one memory persisted.
2540        let n: i64 = conn
2541            .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
2542            .unwrap();
2543        assert_eq!(n, 1);
2544    }
2545
2546    // GAP-SG-54: `ingest --force-merge` parses and sets the update flag.
2547    #[test]
2548    fn ingest_force_merge_flag_parses() {
2549        use crate::cli::{Cli, Commands};
2550        use clap::Parser;
2551        let cli = Cli::try_parse_from(["sqlite-graphrag", "ingest", "./docs", "--force-merge"])
2552            .expect("parse");
2553        match cli.command {
2554            Some(Commands::Ingest(a)) => assert!(a.force_merge),
2555            other => panic!("expected ingest, got {other:?}"),
2556        }
2557        // Default is off.
2558        let cli2 = Cli::try_parse_from(["sqlite-graphrag", "ingest", "./docs"]).expect("parse");
2559        match cli2.command {
2560            Some(Commands::Ingest(a)) => assert!(!a.force_merge),
2561            other => panic!("expected ingest, got {other:?}"),
2562        }
2563    }
2564
2565    #[test]
2566    fn matches_pattern_suffix() {
2567        assert!(matches_pattern("foo.md", "*.md"));
2568        assert!(!matches_pattern("foo.txt", "*.md"));
2569        assert!(matches_pattern("foo.md", "*"));
2570    }
2571
2572    #[test]
2573    fn matches_pattern_prefix() {
2574        assert!(matches_pattern("README.md", "README*"));
2575        assert!(!matches_pattern("CHANGELOG.md", "README*"));
2576    }
2577
2578    #[test]
2579    fn matches_pattern_exact() {
2580        assert!(matches_pattern("README.md", "README.md"));
2581        assert!(!matches_pattern("readme.md", "README.md"));
2582    }
2583
2584    #[test]
2585    fn derive_kebab_underscore_to_dash() {
2586        let p = PathBuf::from("/tmp/claude_code_headless.md");
2587        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2588        assert_eq!(name, "claude-code-headless");
2589        assert!(!truncated);
2590        assert!(original.is_none());
2591    }
2592
2593    #[test]
2594    fn derive_kebab_uppercase_lowered() {
2595        let p = PathBuf::from("/tmp/README.md");
2596        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2597        assert_eq!(name, "readme");
2598        assert!(!truncated);
2599        assert!(original.is_none());
2600    }
2601
2602    #[test]
2603    fn derive_kebab_strips_non_kebab_chars() {
2604        let p = PathBuf::from("/tmp/some@weird#name!.md");
2605        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2606        assert_eq!(name, "someweirdname");
2607        assert!(!truncated);
2608        assert!(original.is_none());
2609    }
2610
2611    // Bug M-A3: NFD-based unicode normalization preserves base letters of
2612    // accented characters instead of dropping them entirely.
2613    #[test]
2614    fn derive_kebab_folds_accented_letters_to_ascii() {
2615        let p = PathBuf::from("/tmp/açaí.md");
2616        let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2617        assert_eq!(name, "acai", "got '{name}'");
2618    }
2619
2620    #[test]
2621    fn derive_kebab_handles_naive_with_diaeresis() {
2622        let p = PathBuf::from("/tmp/naïve-test.md");
2623        let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2624        assert_eq!(name, "naive-test", "got '{name}'");
2625    }
2626
2627    #[test]
2628    fn derive_kebab_drops_emoji_keeps_word() {
2629        let p = PathBuf::from("/tmp/🚀-rocket.md");
2630        let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2631        assert_eq!(name, "rocket", "got '{name}'");
2632    }
2633
2634    #[test]
2635    fn derive_kebab_mixed_unicode_emoji_keeps_letters() {
2636        let p = PathBuf::from("/tmp/açaí🦜.md");
2637        let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2638        assert_eq!(name, "acai", "got '{name}'");
2639    }
2640
2641    #[test]
2642    fn derive_kebab_pure_emoji_yields_empty() {
2643        let p = PathBuf::from("/tmp/🦜🚀🌟.md");
2644        let (name, _, _) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2645        assert!(name.is_empty(), "got '{name}'");
2646    }
2647
2648    #[test]
2649    fn derive_kebab_collapses_consecutive_dashes() {
2650        let p = PathBuf::from("/tmp/a__b___c.md");
2651        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2652        assert_eq!(name, "a-b-c");
2653        assert!(!truncated);
2654        assert!(original.is_none());
2655    }
2656
2657    #[test]
2658    fn derive_kebab_truncates_to_60_chars() {
2659        let p = PathBuf::from(format!("/tmp/{}.md", "a".repeat(80)));
2660        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2661        assert!(name.len() <= 60, "got len {}", name.len());
2662        assert!(truncated);
2663        assert!(original.is_some());
2664        assert!(original.unwrap().len() > 60);
2665    }
2666
2667    #[test]
2668    fn collect_files_finds_md_files() {
2669        let tmp = tempfile::tempdir().expect("tempdir");
2670        std::fs::write(tmp.path().join("a.md"), "x").unwrap();
2671        std::fs::write(tmp.path().join("b.md"), "y").unwrap();
2672        std::fs::write(tmp.path().join("c.txt"), "z").unwrap();
2673        let mut out = Vec::new();
2674        collect_files(tmp.path(), "*.md", false, &mut out).expect("collect");
2675        assert_eq!(out.len(), 2, "should find 2 .md files, got {out:?}");
2676    }
2677
2678    #[test]
2679    fn collect_files_recursive_descends_subdirs() {
2680        let tmp = tempfile::tempdir().expect("tempdir");
2681        let sub = tmp.path().join("sub");
2682        std::fs::create_dir(&sub).unwrap();
2683        std::fs::write(tmp.path().join("a.md"), "x").unwrap();
2684        std::fs::write(sub.join("b.md"), "y").unwrap();
2685        let mut out = Vec::new();
2686        collect_files(tmp.path(), "*.md", true, &mut out).expect("collect");
2687        assert_eq!(out.len(), 2);
2688    }
2689
2690    #[test]
2691    fn collect_files_non_recursive_skips_subdirs() {
2692        let tmp = tempfile::tempdir().expect("tempdir");
2693        let sub = tmp.path().join("sub");
2694        std::fs::create_dir(&sub).unwrap();
2695        std::fs::write(tmp.path().join("a.md"), "x").unwrap();
2696        std::fs::write(sub.join("b.md"), "y").unwrap();
2697        let mut out = Vec::new();
2698        collect_files(tmp.path(), "*.md", false, &mut out).expect("collect");
2699        assert_eq!(out.len(), 1);
2700    }
2701
2702    // ── v1.0.31 A10: name truncation warns and collisions are auto-resolved ──
2703
2704    #[test]
2705    fn derive_kebab_long_basename_truncated_within_cap() {
2706        let p = PathBuf::from(format!("/tmp/{}.md", "a".repeat(120)));
2707        let (name, truncated, original) = derive_kebab_name(&p, DERIVED_NAME_MAX_LEN);
2708        assert!(
2709            name.len() <= DERIVED_NAME_MAX_LEN,
2710            "truncated name must respect cap; got {} chars",
2711            name.len()
2712        );
2713        assert!(!name.is_empty());
2714        assert!(truncated);
2715        assert!(original.is_some());
2716    }
2717
2718    #[test]
2719    fn unique_name_returns_base_when_free() {
2720        let taken: BTreeSet<String> = BTreeSet::new();
2721        let resolved = unique_name("note", &taken).expect("must resolve");
2722        assert_eq!(resolved, "note");
2723    }
2724
2725    #[test]
2726    fn unique_name_appends_first_free_suffix_on_collision() {
2727        let mut taken: BTreeSet<String> = BTreeSet::new();
2728        taken.insert("note".to_string());
2729        taken.insert("note-1".to_string());
2730        let resolved = unique_name("note", &taken).expect("must resolve");
2731        assert_eq!(resolved, "note-2");
2732    }
2733
2734    #[test]
2735    fn unique_name_errors_after_collision_cap() {
2736        let mut taken: BTreeSet<String> = BTreeSet::new();
2737        taken.insert("note".to_string());
2738        for i in 1..=MAX_NAME_COLLISION_SUFFIX {
2739            taken.insert(format!("note-{i}"));
2740        }
2741        let err = unique_name("note", &taken).expect_err("must surface error");
2742        assert!(matches!(err, AppError::Validation(_)));
2743    }
2744
2745    // ── v1.0.32 Onda 4B: in-process pipeline validation ──
2746
2747    #[test]
2748    fn validate_relation_format_accepts_valid_relations() {
2749        use crate::parsers::{is_canonical_relation, validate_relation_format};
2750        assert!(validate_relation_format("applies_to").is_ok());
2751        assert!(validate_relation_format("depends_on").is_ok());
2752        assert!(validate_relation_format("implements").is_ok());
2753        assert!(validate_relation_format("").is_err());
2754        assert!(is_canonical_relation("applies_to"));
2755        assert!(!is_canonical_relation("implements"));
2756    }
2757
2758    // ── v1.0.40 H-A1: --low-memory flag and SQLITE_GRAPHRAG_LOW_MEMORY env var ──
2759
2760    use serial_test::serial;
2761
2762    /// Helper: scrubs the env var around a closure to keep tests deterministic.
2763    fn with_env_var<F: FnOnce()>(value: Option<&str>, f: F) {
2764        let key = "SQLITE_GRAPHRAG_LOW_MEMORY";
2765        let prev = std::env::var(key).ok();
2766        match value {
2767            Some(v) => std::env::set_var(key, v),
2768            None => std::env::remove_var(key),
2769        }
2770        f();
2771        match prev {
2772            Some(p) => std::env::set_var(key, p),
2773            None => std::env::remove_var(key),
2774        }
2775    }
2776
2777    #[test]
2778    #[serial]
2779    fn env_low_memory_enabled_unset_returns_false() {
2780        with_env_var(None, || assert!(!env_low_memory_enabled()));
2781    }
2782
2783    #[test]
2784    #[serial]
2785    fn env_low_memory_enabled_empty_returns_false() {
2786        with_env_var(Some(""), || assert!(!env_low_memory_enabled()));
2787    }
2788
2789    #[test]
2790    #[serial]
2791    fn env_low_memory_enabled_truthy_values_return_true() {
2792        for v in ["1", "true", "TRUE", "yes", "YES", "on", "On"] {
2793            with_env_var(Some(v), || {
2794                assert!(env_low_memory_enabled(), "value {v:?} should be truthy")
2795            });
2796        }
2797    }
2798
2799    #[test]
2800    #[serial]
2801    fn env_low_memory_enabled_falsy_values_return_false() {
2802        for v in ["0", "false", "FALSE", "no", "off"] {
2803            with_env_var(Some(v), || {
2804                assert!(!env_low_memory_enabled(), "value {v:?} should be falsy")
2805            });
2806        }
2807    }
2808
2809    #[test]
2810    #[serial]
2811    fn env_low_memory_enabled_unrecognized_value_returns_false() {
2812        with_env_var(Some("maybe"), || assert!(!env_low_memory_enabled()));
2813    }
2814
2815    #[test]
2816    #[serial]
2817    fn resolve_parallelism_flag_forces_one_overriding_explicit_value() {
2818        with_env_var(None, || {
2819            assert_eq!(resolve_parallelism(true, Some(4)), 1);
2820            assert_eq!(resolve_parallelism(true, Some(8)), 1);
2821            assert_eq!(resolve_parallelism(true, None), 1);
2822        });
2823    }
2824
2825    #[test]
2826    #[serial]
2827    fn resolve_parallelism_env_forces_one_when_flag_off() {
2828        with_env_var(Some("1"), || {
2829            assert_eq!(resolve_parallelism(false, Some(4)), 1);
2830            assert_eq!(resolve_parallelism(false, None), 1);
2831        });
2832    }
2833
2834    #[test]
2835    #[serial]
2836    fn resolve_parallelism_falsy_env_does_not_override() {
2837        with_env_var(Some("0"), || {
2838            assert_eq!(resolve_parallelism(false, Some(4)), 4);
2839        });
2840    }
2841
2842    #[test]
2843    #[serial]
2844    fn resolve_parallelism_explicit_value_when_low_memory_off() {
2845        with_env_var(None, || {
2846            assert_eq!(resolve_parallelism(false, Some(3)), 3);
2847            assert_eq!(resolve_parallelism(false, Some(1)), 1);
2848        });
2849    }
2850
2851    #[test]
2852    #[serial]
2853    fn resolve_parallelism_default_when_unset() {
2854        with_env_var(None, || {
2855            let p = resolve_parallelism(false, None);
2856            assert!((1..=4).contains(&p), "default must be in [1, 4]; got {p}");
2857        });
2858    }
2859
2860    #[test]
2861    fn ingest_args_parses_low_memory_flag_via_clap() {
2862        use clap::Parser;
2863        // Parse a synthetic Cli that contains the `ingest` subcommand. We rely
2864        // on the public `Cli` definition so the flag is wired end-to-end.
2865        let cli = crate::cli::Cli::try_parse_from([
2866            "sqlite-graphrag",
2867            "ingest",
2868            "/tmp/dummy",
2869            "--type",
2870            "document",
2871            "--low-memory",
2872        ])
2873        .expect("parse must succeed");
2874        match cli.command {
2875            Some(crate::cli::Commands::Ingest(args)) => {
2876                assert!(args.low_memory, "--low-memory must set field to true");
2877            }
2878            _ => panic!("expected Ingest subcommand"),
2879        }
2880    }
2881
2882    #[test]
2883    fn ingest_args_low_memory_defaults_false() {
2884        use clap::Parser;
2885        let cli = crate::cli::Cli::try_parse_from([
2886            "sqlite-graphrag",
2887            "ingest",
2888            "/tmp/dummy",
2889            "--type",
2890            "document",
2891        ])
2892        .expect("parse must succeed");
2893        match cli.command {
2894            Some(crate::cli::Commands::Ingest(args)) => {
2895                assert!(!args.low_memory, "default must be false");
2896            }
2897            _ => panic!("expected Ingest subcommand"),
2898        }
2899    }
2900
2901    // ── GAP-SG-06: --dry-run reports chunk and token counts ──
2902
2903    #[test]
2904    fn dry_run_budget_event_serializes_chunk_and_token_counts() {
2905        let ev = IngestDryRunBudget {
2906            budget: true,
2907            file: "/tmp/doc.md",
2908            name: "doc",
2909            bytes: 1234,
2910            chunk_count: 3,
2911            token_count: 567,
2912            partition_count: 1,
2913            exceeds_limits: false,
2914        };
2915        let json = serde_json::to_string(&ev).expect("serialize budget event");
2916        assert!(json.contains("\"chunk_count\":3"), "got: {json}");
2917        assert!(json.contains("\"token_count\":567"), "got: {json}");
2918        assert!(json.contains("\"partition_count\":1"), "got: {json}");
2919        assert!(json.contains("\"exceeds_limits\":false"), "got: {json}");
2920    }
2921
2922    #[test]
2923    fn assess_body_budget_feeds_dry_run_with_positive_counts() {
2924        // The dry-run path feeds chunking::assess_body_budget; a representative
2925        // body must report a positive chunk and token count.
2926        let body = "# Title\n\nsome representative body text for the budget.";
2927        let budget = chunking::assess_body_budget(body);
2928        assert!(budget.chunk_count >= 1);
2929        assert!(budget.approx_tokens >= 1);
2930        assert_eq!(budget.partition_count, 1);
2931    }
2932}