rag-rat 0.18.0

CLI and MCP entrypoint for indexing repositories into local source, graph, history, and memory evidence.
use super::*;

/// Render a complete, commented `rag-rat.toml`. The lines that reflect *this* repo's choices —
/// root, the language bindings, the chosen embedding model, the oracle opt-in — are active; every
/// other table is emitted as commented defaults so the full config surface is discoverable without
/// leaving the file (the bare-bindings stub used to hide it). No `database` key is emitted: the
/// keyless config resolves to the machine-global store (the A7 default), with the deprecated
/// per-repo opt-out documented as a comment. See `docs/config.md`.
pub(crate) fn render_config(plan: &InitPlan) -> String {
    let mut text = String::new();
    text.push_str(
        "# rag-rat configuration — generated by `rag-rat init`; edit freely.\n# Active lines \
         below reflect this repo; commented tables show the tunable defaults.\n# Full reference: \
         docs/config.md.\n\n",
    );

    text.push_str("[index]\n");
    text.push_str(&format!("root = {}\n", toml_string(&plan.root_value)));
    // No `database` key: the index + memories live in the machine-global store by default (A7),
    // which is what makes them survive `git clean -fdx` / a deleted checkout. The commented line
    // documents the deprecated per-repo opt-out without activating it.
    text.push_str(
        "# The index and repo memories live in the machine-global database by default\n# \
         ($XDG_DATA_HOME/rag-rat/rag-rat.sqlite; override the location with RAG_RAT_DATA_DIR).\n# \
         Uncomment to keep this repo on its own per-repo file instead (deprecated):\n# database = \
         \".rag-rat/index.sqlite\"\n\n",
    );

    text.push_str(
        "# Language → directories to index. Each language indexes its default file extensions in \
         the\n# listed dirs; a `cpp` binding also claims `.h` headers (indexed as C++), a `c` \
         binding\n# claims `.c` + `.h`. Add or remove languages and directories as your layout \
         needs.\n",
    );
    text.push_str("[target_bindings]\n");
    for language in &plan.languages {
        let dirs = plan.bindings.get(language).cloned().unwrap_or_default();
        text.push_str(&format!("{} = [{}]\n", language.as_str(), quoted_paths(&dirs)));
    }
    text.push('\n');

    text.push_str(
        "# Richer targets (uncomment to use): a named target with an explicit kind and custom \
         file\n# globs — the only way to bind generated / test / docs trees or a non-default file \
         set.\n# `kind` is source|generated|docs|tests; `include`/`exclude` are `**/*.<ext>` \
         globs.\n# [[target]]\n# name = \"generated-bindings\"\n# language = \"typescript\"\n# \
         directories = [\"packages/app/src/generated\"]\n# kind = \"generated\"\n# include = \
         [\"**/*.ts\"]\n# exclude = [\"**/*.map\"]\n\n",
    );

    text.push_str("[llm.embedding]\n");
    text.push_str(&format!("# {}\n", backend_label(plan.backend)));
    text.push_str(&format!("model = {}\n\n", toml_string(plan.backend.as_str())));

    text.push_str(
        "# Embedding runtime tuning (defaults shown; uncomment to override).\n# \
         [llm.embedding.runtime]\n# batch_size = 64\n# ort_threads = 4          # ONNX intra-op \
         threads\n# omp_threads = 1\n# max_embedding_chars = 4000\n\n",
    );

    text.push_str(
        "# Remote Ollama embedding (optional; defaults shown; uncomment to offload \
         embedding).\n# [llm.embedding.remote]\n# endpoint = \"http://localhost:11434\"\n# model = \
         \"all-minilm\"          # Ollama-side model name for the selected embedding model\n# \
         batch_size = 256          # texts per /api/embed request\n# concurrency = 1           # \
         CONNECT-safe; cookbook/ephemeral default = 32\n# max_batch_chars = 384000 # max input chars per \
         /api/embed request\n# request_timeout_s = 60\n# num_ctx = 4096            # optional \
         Ollama context window\n\n",
    );

    text.push_str(
        "# Init wizard cookbook catalog (optional). These entries only affect `rag-rat init` \
         selectors;\n# runtime uses the selected `[llm.embedding.remote] cookbook` and `gpu` \
         strings verbatim.\n# [init.cookbooks.modal]\n# label = \"Modal\"\n# command = \
         \"@rag-rat/cookbook modal\"\n# gpus = [\"T4\", \"L4\", \"A10\", \"H100\"]\n#\n# \
         [init.cookbooks.my-provider]\n# label = \"My Provider\"\n# command = \
         \"./recipes/my-provider.mjs\"\n# gpus = [\"small\", \"large\"]\n\n",
    );

    text.push_str(
        "# Background file watcher — keeps the index fresh as files change (defaults shown).\n# \
         [watch]\n# enabled = true\n# debounce_ms = 400           # quiet window before a reindex \
         pass\n# max_latency_ms = 2500       # force a pass after this much continuous \
         activity\n# periodic_sweep_secs = 300   # backstop pass interval, 0 disables\n\n",
    );

    text.push_str(
        "# Check crates.io for a newer rag-rat and surface it. Best-effort, cached, never \
         blocks.\n# [version_check]\n# enabled = true\n\n",
    );

    text.push_str(
        "# Debug logging — per-process files under `.rag-rat/logs/` (off by default). Traces the \
         git-hook\n# maintenance / reconcile / embedding lifecycle. `RAG_RAT_LOG` \
         (RUST_LOG-style) overrides\n# `level`/`filter` and can force-enable it for a single \
         command.\n# [log]\n# enabled = false\n# level = \"info\"              # off | error | \
         warn | info | debug | trace\n# filter = \"rag_rat_core::index::ai=debug\"   # optional \
         per-subsystem directives\n# format = \"text\"             # text | json\n# \
         retention_days = 7          # prune log files older than this on startup\n# max_files = \
         200             # cap on files kept (oldest pruned first)\n\n",
    );

    text.push_str("[oracle]\n");
    text.push_str(
        "# Background auto-refresh of compiler-grade (SCIP) importance ranking. Needs a \
         language\n# tool on PATH (e.g. rust-analyzer); runs throttled in the MCP server only. \
         Default off.\n",
    );
    text.push_str(&format!("auto_run = {}\n", plan.oracle_auto_run));
    text.push_str(
        "# auto_run_quiet_period_secs = 900    # run only after the index has been this quiet\n# \
         auto_run_min_interval_secs = 21600  # and at most this often, regardless of churn\n",
    );
    text
}
pub(crate) fn quoted_paths(paths: &[PathBuf]) -> String {
    paths.iter().map(|path| toml_string(&display_rel(path))).collect::<Vec<_>>().join(", ")
}
pub(crate) fn config_root_value(root: &Path, config_path: &Path) -> String {
    let Some(parent) = config_path.parent().filter(|path| !path.as_os_str().is_empty()) else {
        return ".".to_string();
    };
    if config_path.is_absolute() {
        absolute_config_root_value(root, parent)
    } else {
        relative_config_root_value(parent)
    }
}
pub(crate) fn absolute_config_root_value(root: &Path, parent: &Path) -> String {
    // `root` comes from `Config::load`, which CANONICALIZES it (symlinks resolved); `parent` comes
    // straight from the config path and does NOT. On any symlinked prefix — macOS `/tmp` →
    // `/private/tmp`, a symlinked `$HOME` — the two forms diverge and `strip_prefix` fails, so we
    // wrongly emit an absolute `[index] root` instead of `.`/`..`. Canonicalize BOTH before
    // stripping so they share one form (no-op when there's no symlink, i.e. everywhere on Linux
    // CI). Fall back to the raw path if canonicalize fails (e.g. the dir doesn't exist yet) —
    // same behavior as before for that edge. (#446)
    let root_canon = root.canonicalize();
    let parent_canon = parent.canonicalize();
    let root_ref = root_canon.as_deref().unwrap_or(root);
    let parent_ref = parent_canon.as_deref().unwrap_or(parent);
    if let Ok(relative_parent) = parent_ref.strip_prefix(root_ref) {
        return relative_config_root_value(relative_parent);
    }
    root.display().to_string()
}
pub(crate) fn relative_config_root_value(parent: &Path) -> String {
    let depth = parent.components().filter(normal_component).count();
    if depth == 0 {
        ".".to_string()
    } else {
        std::iter::repeat_n("..", depth).collect::<Vec<_>>().join("/")
    }
}
pub(crate) fn normal_component(component: &std::path::Component<'_>) -> bool {
    matches!(component, std::path::Component::Normal(_))
}
pub(crate) fn toml_string(value: &str) -> String {
    format!("{value:?}")
}
pub(crate) fn display_rel(path: &Path) -> String {
    let text = path.to_string_lossy().replace('\\', "/");
    if text.is_empty() { ".".to_string() } else { text }
}
pub(crate) fn supported_languages() -> Vec<Language> {
    Language::all().to_vec()
}

// Unix-only: the sole test needs a real symlink (`std::os::unix::fs::symlink`), so gate the whole
// module — otherwise `use super::*` is an unused import on Windows under `-D unused-imports`.
#[cfg(all(test, unix))]
mod tests {
    use super::*;

    // A canonicalized `root` vs a symlinked `parent` must still resolve to "." — the macOS-only
    // failure (`/tmp` → `/private/tmp`) reproduced on Linux with a real symlink (#446). Before the
    // canonicalize-both fix, `strip_prefix` failed and this emitted the absolute path.
    #[test]
    fn absolute_root_value_is_dot_when_parent_reaches_root_through_a_symlink() {
        use std::os::unix::fs::symlink;

        let tmp = tempfile::tempdir().unwrap();
        let real = tmp.path().join("real");
        std::fs::create_dir_all(&real).unwrap();
        let link = tmp.path().join("link");
        symlink(&real, &link).unwrap();

        // `root` = canonicalized real dir (what Config::load stores); `parent` reaches the same dir
        // through the symlink (what a config path under `link/` yields).
        let root = real.canonicalize().unwrap();
        assert_ne!(link, root, "the symlinked path must differ from the canonical one");
        assert_eq!(config_root_value(&root, &link.join("rag-rat.toml")), ".");

        // One level deeper through the symlink → "..".
        let sub = link.join("nested");
        std::fs::create_dir_all(&sub).unwrap();
        assert_eq!(config_root_value(&root, &sub.join("rag-rat.toml")), "..");
    }
}