use super::wizard::{self, HookConflict, WizardResult, render_chained_hook};
use super::*;
use crate::{hook_script, install_hook, is_rag_rat_hook, make_executable, write_atomic};
pub(crate) fn run(args: &crate::cli::InitArgs, config_path: &str) -> anyhow::Result<()> {
let options = InitOptions::from_args(args, config_path);
let _terminal_reset = TerminalResetGuard::install_if_interactive(!options.yes)?;
let root = env::current_dir()?.canonicalize()?;
if options.yes {
let scan = scan_repo(&root)?;
let root_value = config_root_value(&root, &options.config_path);
return run_non_interactive(&options, root_value, &scan);
}
run_interactive(&options)
}
fn run_non_interactive(
options: &InitOptions,
root_value: String,
scan: &RepoScan,
) -> anyhow::Result<()> {
let plan = default_plan(root_value, scan);
if plan.bindings.is_empty() {
anyhow::bail!(
"init found no indexable source to bind — every detected language resolved to a \
dependency tree (e.g. a virtualenv). Nothing to configure."
);
}
let config_text = render_config(&plan);
if options.dry_run {
println!("{config_text}");
return Ok(());
}
if options.config_path.exists() && !options.force && !options.yes {
let overwrite = Confirm::new()
.with_prompt(format!("Overwrite {}?", options.config_path.display()))
.default(false)
.interact()?;
if !overwrite {
anyhow::bail!("init cancelled; {} already exists", options.config_path.display());
}
}
if let Some(parent) = options.config_path.parent().filter(|path| !path.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
fs::write(&options.config_path, config_text)?;
eprintln!("init: wrote {}", options.config_path.display());
let config = Config::load(&options.config_path)?;
apply_embedding_runtime_env(&config.llm.embedding.runtime);
let db = setup_index(&config)?;
setup_model_and_reconcile(&config, &db, options.yes)?;
offer_mcp_install(&config, &options.config_path, options.yes)?;
offer_hooks_install(&config, options.yes)?;
eprintln!("init: complete");
Ok(())
}
fn run_interactive(options: &InitOptions) -> anyhow::Result<()> {
let existing = load_existing_for_wizard(options)?;
let scan_root = interactive_scan_root(Some(&existing))?;
let scan = scan_repo(&scan_root)?;
let Some(result) =
wizard::run_wizard(scan, existing.config, &options.config_path, scan_root.clone())?
else {
eprintln!("init: cancelled");
return Ok(());
};
if options.dry_run {
println!("{}", result.toml);
return Ok(());
}
if let Some(parent) = options.config_path.parent().filter(|path| !path.as_os_str().is_empty()) {
fs::create_dir_all(parent)?;
}
fs::write(&options.config_path, &result.toml)?;
eprintln!("init: wrote {}", options.config_path.display());
let config = Config::load(&options.config_path)?;
apply_embedding_runtime_env(&config.llm.embedding.runtime);
let db = setup_index(&config)?;
setup_model_and_reconcile(&config, &db, false)?;
offer_mcp_install(&config, &options.config_path, false)?;
apply_wizard_hooks(&config, &result)?;
eprintln!("init: complete");
Ok(())
}
struct ExistingWizardConfig {
config: Option<(String, Config)>,
local_root: Option<PathBuf>,
}
fn load_existing_for_wizard(options: &InitOptions) -> anyhow::Result<ExistingWizardConfig> {
if !options.config_path.exists() || options.force {
return Ok(ExistingWizardConfig { config: None, local_root: None });
}
let raw = fs::read_to_string(&options.config_path)?;
let local_root = local_config_root_from_raw(&raw, &options.config_path)?;
let config = match Config::load(&options.config_path) {
Ok(config) => Some((raw, config)),
Err(err) => {
eprintln!(
"init: existing config {} could not be loaded ({err}); starting from a fresh draft",
options.config_path.display()
);
None
},
};
Ok(ExistingWizardConfig { config, local_root })
}
fn interactive_scan_root(existing: Option<&ExistingWizardConfig>) -> anyhow::Result<PathBuf> {
if let Some(root) = existing.and_then(|existing| existing.local_root.clone()) {
return Ok(root);
}
if let Some((_, config)) = existing.and_then(|existing| existing.config.as_ref()) {
return Ok(config.root.clone());
}
Ok(env::current_dir()?.canonicalize()?)
}
fn local_config_root_from_raw(raw: &str, config_path: &Path) -> anyhow::Result<Option<PathBuf>> {
let Ok(doc) = raw.parse::<toml_edit::DocumentMut>() else {
return Ok(None);
};
let Some(root) = doc
.get("index")
.and_then(|item| item.as_table_like())
.and_then(|table| table.get("root"))
.and_then(|item| item.as_str())
else {
return Ok(None);
};
let root_path = Path::new(root);
let candidate = if root_path.is_absolute() {
root_path.to_path_buf()
} else {
config_dir(config_path)?.join(root_path)
};
match candidate.canonicalize() {
Ok(path) => Ok(Some(path)),
Err(_) => Ok(None),
}
}
fn config_dir(config_path: &Path) -> anyhow::Result<PathBuf> {
let parent = config_path.parent().filter(|path| !path.as_os_str().is_empty());
let dir = match (config_path.is_absolute(), parent) {
(true, Some(parent)) => parent.to_path_buf(),
(true, None) => PathBuf::from("/"),
(false, Some(parent)) => env::current_dir()?.join(parent),
(false, None) => env::current_dir()?,
};
Ok(dir.canonicalize().unwrap_or(dir))
}
fn apply_wizard_hooks(config: &Config, result: &WizardResult) -> anyhow::Result<()> {
if result.hooks.git {
apply_git_hooks(config, &result.hook_conflicts)?;
}
if result.hooks.claude {
crate::claude_hooks(config, "install", result.hooks.claude_global)?;
}
Ok(())
}
fn apply_git_hooks(
config: &Config,
conflicts: &std::collections::HashMap<&'static str, HookConflict>,
) -> anyhow::Result<()> {
let git = match git_paths(&config.root) {
Ok(git) => git,
Err(err) => {
eprintln!("init: skipped git hooks (not a git worktree: {err})");
return Ok(());
},
};
if conflicts.values().any(|c| *c == HookConflict::Abort) {
eprintln!("init: skipped git hooks (conflict aborted)");
return Ok(());
}
fs::create_dir_all(&git.hooks_dir)?;
let mut installed = Vec::new();
for &hook in crate::MANAGED_HOOKS {
let path = git.hooks_dir.join(hook);
let foreign = path.exists() && !is_rag_rat_hook(&path)?;
match conflicts.get(hook).copied() {
Some(HookConflict::Skip) | Some(HookConflict::UninstallRagRatOnly) => {
},
Some(HookConflict::Overwrite) => {
write_atomic(&path, hook_script(hook).as_bytes())?;
make_executable(&path)?;
installed.push(hook);
},
Some(HookConflict::Chain) => {
let original = fs::read_to_string(&path).unwrap_or_default();
write_atomic(&path, render_chained_hook(&original, hook).as_bytes())?;
make_executable(&path)?;
installed.push(hook);
},
Some(HookConflict::Abort) => unreachable!("handled above"),
None => {
if foreign {
eprintln!(
"init: leaving unmanaged hook {} in place (no resolution recorded)",
path.display()
);
} else {
install_hook(&git.hooks_dir, hook)?;
installed.push(hook);
}
},
}
}
eprintln!("init: installed git hooks in {} ({:?})", git.hooks_dir.display(), installed);
Ok(())
}
pub(crate) fn default_plan(root_value: String, scan: &RepoScan) -> InitPlan {
let languages = supported_languages()
.into_iter()
.filter(|language| scan.language_counts.get(language).copied().unwrap_or_default() > 0)
.collect::<Vec<_>>();
let languages = if languages.is_empty() { vec![Language::Rust] } else { languages };
let bindings: BTreeMap<Language, Vec<PathBuf>> = languages
.iter()
.filter_map(|language| {
let candidates = candidate_dirs(scan, *language);
let defaults = candidates
.iter()
.filter(|candidate| candidate.default)
.map(|candidate| candidate.path.clone())
.collect::<Vec<_>>();
if !defaults.is_empty() {
return Some((*language, defaults));
}
if *language == Language::Python && !candidates.is_empty() {
return None;
}
Some((*language, vec![PathBuf::from(".")]))
})
.collect();
let languages =
languages.into_iter().filter(|language| bindings.contains_key(language)).collect();
let backend = recommend_backend(estimated_chunks(scan.total_source_bytes));
InitPlan { root_value, languages, bindings, backend, oracle_auto_run: false }
}
pub(crate) fn setup_index(config: &Config) -> anyhow::Result<IndexDatabase> {
eprintln!("init: migrating SQLite schema");
let migration = IndexDatabase::migrate(&config.database)?;
if migration.state != rag_rat_core::index::schema::SchemaState::Compatible {
anyhow::bail!("{}", migration.message);
}
eprintln!("init: indexing discovered files");
IndexDatabase::index_discover_with_progress(config, render_index_progress)
}
pub(crate) fn setup_model_and_reconcile(
config: &Config,
db: &IndexDatabase,
assume_yes: bool,
) -> anyhow::Result<()> {
let backend = config.llm.embedding.backend;
let Some(model_id) = backend.model_id() else {
eprintln!(
"init: embeddings disabled (model = \"none\") — structural + BM25 search only, no \
vector backfill"
);
return Ok(());
};
let install = assume_yes
|| Confirm::new()
.with_prompt(format!(
"Install the {} embedding model and reconcile vectors now?",
backend.as_str()
))
.default(true)
.interact()?;
if !install {
eprintln!("init: skipped model install and reconcile");
return Ok(());
}
eprintln!("init: installing model {model_id}");
let remote = config.llm.embedding.remote.as_ref();
match db.install_model(model_id, remote) {
Ok(model) => eprintln!("init: model status {} {}", model.model_id, model.status),
Err(err)
if remote.is_none()
&& (model_id == FASTEMBED_MODEL_ID || model_id == MODEL2VEC_MODEL_ID) =>
{
eprintln!("init: {} install failed: {err}", backend.as_str());
eprintln!("init: falling back to {HASH_MODEL_ID}");
db.install_model(HASH_MODEL_ID, None)?;
},
Err(err) => return Err(err),
}
eprintln!("init: reconciling embeddings");
db.reconcile_with_options_progress(
ReconcileOptions {
limit: None,
batch_size: Some(config.llm.embedding.runtime.batch_size),
force: false,
until_clean: true,
changed_first: true,
max_seconds: None,
max_embedding_chars: config.llm.embedding.runtime.max_embedding_chars,
intra_threads: config.llm.embedding.runtime.ort_threads.map(|n| n as usize),
provision_remote: true,
},
render_reconcile_progress,
)?;
Ok(())
}
pub(crate) fn offer_mcp_install(
config: &Config,
config_path: &Path,
assume_yes: bool,
) -> anyhow::Result<()> {
let absolute_config = absolute_config_path(config, config_path)?;
if assume_yes
|| Confirm::new()
.with_prompt("Install rag-rat MCP for Claude Code?")
.default(false)
.interact()?
{
install_claude_mcp(&absolute_config)?;
}
if assume_yes
|| Confirm::new().with_prompt("Install rag-rat MCP for Codex?").default(false).interact()?
{
install_codex_mcp(&absolute_config)?;
}
Ok(())
}
pub(crate) fn offer_hooks_install(config: &Config, assume_yes: bool) -> anyhow::Result<()> {
let install = assume_yes
|| Confirm::new()
.with_prompt("Install rag-rat git maintenance hooks?")
.default(false)
.interact()?;
if !install {
return Ok(());
}
let git = git_paths(&config.root)?;
fs::create_dir_all(&git.hooks_dir)?;
for hook in crate::MANAGED_HOOKS {
crate::install_hook(&git.hooks_dir, hook)?;
}
eprintln!("init: installed hooks in {}", git.hooks_dir.display());
Ok(())
}
pub(crate) fn install_claude_mcp(config_path: &Path) -> anyhow::Result<()> {
let exe = current_exe_for_mcp()?;
let status = Command::new("claude")
.arg("mcp")
.arg("add")
.arg("--scope")
.arg("project")
.arg("rag-rat")
.arg("--")
.arg(&exe)
.arg("mcp")
.arg("--config")
.arg(config_path)
.status();
match status {
Ok(status) if status.success() => eprintln!("init: installed Claude Code MCP server"),
Ok(status) => eprintln!("init: claude mcp add exited with status {status}"),
Err(err) => eprintln!("init: could not run claude mcp add: {err}"),
}
Ok(())
}
pub(crate) fn install_codex_mcp(config_path: &Path) -> anyhow::Result<()> {
let exe = current_exe_for_mcp()?;
let status = Command::new("codex")
.arg("mcp")
.arg("add")
.arg("rag-rat")
.arg("--")
.arg(&exe)
.arg("mcp")
.arg("--config")
.arg(config_path)
.status();
match status {
Ok(status) if status.success() => eprintln!("init: installed Codex MCP server"),
Ok(status) => {
eprintln!("init: codex mcp add exited with status {status}");
print_codex_config_snippet(&exe, config_path);
},
Err(err) => {
eprintln!("init: could not run codex mcp add: {err}");
print_codex_config_snippet(&exe, config_path);
},
}
Ok(())
}
pub(crate) fn current_exe_for_mcp() -> anyhow::Result<PathBuf> {
env::current_exe().map_err(Into::into)
}
pub(crate) fn print_codex_config_snippet(exe: &Path, config_path: &Path) {
eprintln!(
"Add this to ~/.codex/config.toml if your Codex build does not support `codex mcp add`:"
);
eprintln!("[mcp_servers.rag-rat]");
eprintln!("command = {:?}", exe.display().to_string());
eprintln!("args = [\"mcp\", \"--config\", {:?}]", config_path.display().to_string());
}
pub(crate) fn absolute_config_path(config: &Config, config_path: &Path) -> anyhow::Result<PathBuf> {
if config_path.is_absolute() {
Ok(config_path.to_path_buf())
} else {
Ok(config.root.join(config_path).canonicalize()?)
}
}
#[cfg(test)]
mod default_plan_tests {
use std::path::Path;
use super::*;
#[test]
fn wizard_git_hook_install_skips_non_git_roots() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("src")).unwrap();
std::fs::write(root.path().join("src/lib.rs"), "pub fn a() {}\n").unwrap();
let config_path = root.path().join("rag-rat.toml");
std::fs::write(
&config_path,
"[index]\nroot = \".\"\n[target_bindings]\nrust = [\"src\"]\n",
)
.unwrap();
let config = Config::load(&config_path).unwrap();
apply_git_hooks(&config, &std::collections::HashMap::new()).unwrap();
assert!(!root.path().join(".git/hooks").exists());
}
#[test]
fn interactive_reconfigure_scans_configured_root() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("src")).unwrap();
let config_path = root.path().join("rag-rat.toml");
std::fs::write(
&config_path,
"[index]\nroot = \".\"\n[target_bindings]\nrust = [\"src\"]\n",
)
.unwrap();
let options = InitOptions {
yes: false,
dry_run: false,
force: false,
config_path: config_path.clone(),
};
let existing = load_existing_for_wizard(&options).unwrap();
assert_eq!(
interactive_scan_root(Some(&existing)).unwrap(),
root.path().canonicalize().unwrap()
);
assert!(existing.config.is_some());
}
#[test]
fn local_config_root_resolves_relative_to_config_dir() {
let root = tempfile::tempdir().unwrap();
let config_dir = root.path().join("linked");
std::fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("rag-rat.toml");
let raw = "[index]\nroot = \".\"\n";
assert_eq!(
local_config_root_from_raw(raw, &config_path).unwrap(),
Some(config_dir.canonicalize().unwrap())
);
}
#[test]
fn invalid_existing_config_falls_back_to_fresh_draft() {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir_all(root.path().join("src")).unwrap();
let config_path = root.path().join("rag-rat.toml");
std::fs::write(
&config_path,
"[index]\nroot = \".\"\n[target_bindings]\nrust = [\"src\"]\n\
[llm.embedding]\nmodel = \"none\"\n[llm.embedding.remote]\nmodel = \
\"all-minilm\"\nendpoint = \"http://localhost:11434\"\n",
)
.unwrap();
let options = InitOptions {
yes: false,
dry_run: false,
force: false,
config_path: config_path.clone(),
};
let existing = load_existing_for_wizard(&options).unwrap();
assert!(existing.config.is_none());
assert_eq!(existing.local_root, Some(root.path().canonicalize().unwrap()));
}
#[test]
fn env_only_python_writes_no_binding_not_dot() {
let root = Path::new("/repo");
let mut scan = RepoScan::default();
for name in ["a.py", "b.py"] {
*scan.language_counts.entry(Language::Python).or_default() += 1;
add_file_to_dir_counts(
root,
&root.join("env/lib/site-packages/pkg").join(name),
Language::Python,
&mut scan,
)
.unwrap();
}
let plan = default_plan(".".to_string(), &scan);
assert!(
!plan.bindings.contains_key(&Language::Python),
"env-only Python must get NO binding, not `.`: {:?}",
plan.bindings
);
assert!(!plan.languages.contains(&Language::Python));
}
#[test]
fn root_entrypoint_binds_dot() {
let root = Path::new("/repo");
let mut scan = RepoScan::default();
*scan.language_counts.entry(Language::Python).or_default() += 1;
add_file_to_dir_counts(root, &root.join("manage.py"), Language::Python, &mut scan).unwrap();
let plan = default_plan(".".to_string(), &scan);
assert_eq!(plan.bindings.get(&Language::Python), Some(&vec![PathBuf::from(".")]));
}
#[test]
fn root_entrypoint_with_root_venv_does_not_bind_dot() {
let root = Path::new("/repo");
let mut scan = RepoScan::default();
*scan.language_counts.entry(Language::Python).or_default() += 1;
add_file_to_dir_counts(root, &root.join("manage.py"), Language::Python, &mut scan).unwrap();
scan.has_python_virtualenv = true;
let plan = default_plan(".".to_string(), &scan);
assert!(
!plan.bindings.contains_key(&Language::Python),
"a root venv must suppress the `.` default: {:?}",
plan.bindings
);
}
#[test]
fn python_package_dir_still_binds() {
let root = Path::new("/repo");
let mut scan = RepoScan::default();
for name in ["__init__.py", "views.py"] {
*scan.language_counts.entry(Language::Python).or_default() += 1;
add_file_to_dir_counts(
root,
&root.join("myapp").join(name),
Language::Python,
&mut scan,
)
.unwrap();
}
let plan = default_plan(".".to_string(), &scan);
assert_eq!(plan.bindings.get(&Language::Python), Some(&vec![PathBuf::from("myapp")]));
}
#[test]
fn init_propagates_a_remote_install_failure_instead_of_falling_back_to_hash() {
let n = std::sync::atomic::AtomicU64::new(0);
let id = n.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let root = std::env::temp_dir().join(format!("ragrat-r4-{}-{id}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/a.rs"), "pub fn alpha() {}\n").unwrap();
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
std::fs::write(
root.join("rag-rat.toml"),
format!(
"[index]\nroot = \".\"\n\n[target_bindings]\nrust = [\"src\"]\n\n\
[llm.embedding]\nmodel = \"sentence-transformers/all-MiniLM-L6-v2\"\n\n\
[llm.embedding.remote]\nendpoint = \"http://127.0.0.1:{port}\"\nmodel = \
\"all-minilm\"\n"
),
)
.unwrap();
let config = Config::load(root.join("rag-rat.toml")).unwrap();
let db = IndexDatabase::rebuild(&config).unwrap();
let err = setup_model_and_reconcile(&config, &db, true)
.expect_err("a remote-install failure must propagate, not fall back to hash");
assert!(err.to_string().to_lowercase().contains("ollama"), "{err}");
let active = db
.list_models()
.unwrap()
.into_iter()
.find(|m| m.model_id == rag_rat_core::embedding_models::HASH_MODEL_ID)
.unwrap();
assert!(!active.installed, "hash must NOT have been installed as a silent fallback");
let _ = std::fs::remove_dir_all(&root);
}
}