use std::path::{Path, PathBuf};
use crate::cli::policy::PolicyTemplate;
const MCP_CONFIG_RELATIVE_PATHS: &[&str] = &[
"mcp.json",
".mcp.json",
"mcp_settings.json",
".vscode/mcp.json",
".cursor/mcp.json",
".windsurf/mcp.json",
".cline/mcp_settings.json",
".amazonq/mcp.json",
".continue/mcp.json",
".kiro/settings/mcp.json",
];
const PACKAGE_MANAGERS: &[(&str, &str)] = &[
("npm", "npm"),
("pnpm", "pnpm"),
("yarn", "yarn"),
("cargo", "cargo"),
("pip", "pip"),
("uv", "uv"),
("go", "go"),
];
const LOCKFILES: &[(&str, &str)] = &[
("package-lock.json", "package-lock.json"),
("pnpm-lock.yaml", "pnpm-lock.yaml"),
("yarn.lock", "yarn.lock"),
("Cargo.lock", "Cargo.lock"),
("requirements.txt", "requirements.txt"),
("uv.lock", "uv.lock"),
("go.sum", "go.sum"),
];
const ONBOARD_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, serde::Serialize)]
struct OnboardReport {
schema_version: u32,
cwd: String,
#[serde(skip_serializing_if = "Option::is_none")]
repo_root: Option<String>,
requested_mode: String,
detected_shell: String,
ide_configs: Vec<String>,
ai_config_files: Vec<String>,
package_managers: Vec<String>,
lockfiles: Vec<String>,
ci_detected: bool,
mcp_configs: Vec<String>,
tirith: TirithState,
recommended_template: String,
recommendation_reason: String,
next_actions: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
struct TirithState {
hook_installed: bool,
policy_present: bool,
#[serde(skip_serializing_if = "Option::is_none")]
policy_path: Option<String>,
}
pub fn run(mode: Option<&str>, apply: bool, json: bool) -> i32 {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let cwd_str = cwd.display().to_string();
let repo_root = tirith_core::policy::find_repo_root(Some(&cwd_str));
let detect_root = repo_root.clone().unwrap_or_else(|| cwd.clone());
let report = gather_report(&cwd, &detect_root, repo_root.as_deref(), mode);
if json {
if !crate::cli::write_json_stdout(&report, "tirith onboard: failed to write JSON output") {
return 2;
}
return 0;
}
print_human(&report);
if apply {
return apply_actions(&report);
}
crate::cli::note("Run `tirith onboard --apply` to perform the recommended safe actions.");
0
}
fn gather_report(
cwd: &Path,
detect_root: &Path,
repo_root: Option<&Path>,
mode: Option<&str>,
) -> OnboardReport {
let detected_shell = crate::cli::init::detect_shell().to_string();
let ide_configs = detect_dirs(detect_root, &[".cursor", ".vscode"]);
let ai_config_files = detect_ai_config(detect_root);
let package_managers = detect_package_managers();
let lockfiles = detect_lockfiles(detect_root);
let ci_detected = detect_ci(detect_root);
let mcp_configs = detect_mcp_configs(detect_root);
let tirith = detect_tirith_state(cwd, &detected_shell);
let requested_mode = mode.unwrap_or("auto").to_string();
let signals = RecommendationSignals {
mode,
ai_config_count: ai_config_files.len(),
mcp_config_count: mcp_configs.len(),
ci_detected,
};
let (recommended_template, recommendation_reason) = recommend_template(&signals);
let next_actions = build_next_actions(&tirith, recommended_template);
OnboardReport {
schema_version: ONBOARD_SCHEMA_VERSION,
cwd: cwd.display().to_string(),
repo_root: repo_root.map(|p| p.display().to_string()),
requested_mode,
detected_shell,
ide_configs,
ai_config_files,
package_managers,
lockfiles,
ci_detected,
mcp_configs,
tirith,
recommended_template: recommended_template.canonical_name().to_string(),
recommendation_reason,
next_actions,
}
}
fn detect_dirs(root: &Path, names: &[&str]) -> Vec<String> {
names
.iter()
.filter(|name| root.join(name).is_dir())
.map(|name| (*name).to_string())
.collect()
}
const AI_CONFIG_BASENAMES: &[&str] = &[
"CLAUDE.md",
"AGENTS.md",
"AGENTS.override.md",
".cursorrules",
".cursorignore",
".clinerules",
".roorules",
".windsurfrules",
".goosehints",
"copilot-instructions.md",
"GEMINI.md",
"QWEN.md",
"llms.txt",
"llms-full.txt",
];
fn detect_ai_config(root: &Path) -> Vec<String> {
use tirith_core::rules::aifile;
let mut found = Vec::new();
for name in AI_CONFIG_BASENAMES {
let path = root.join(name);
if path.is_file() && aifile::is_ai_config_file(&path) {
found.push((*name).to_string());
}
}
let gh_copilot = root.join(".github").join("copilot-instructions.md");
if gh_copilot.is_file() {
found.push(".github/copilot-instructions.md".to_string());
}
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
let lower = name.to_ascii_lowercase();
if (lower.starts_with(".clinerules-") || lower.starts_with(".roorules-"))
&& entry.path().is_file()
&& aifile::is_ai_config_file(&entry.path())
{
found.push(name.into_owned());
}
}
}
if root.join(".claude").is_dir() {
found.push(".claude/".to_string());
}
let cursor_rules = root.join(".cursor").join("rules");
if cursor_rules.is_dir() {
let has_entry = std::fs::read_dir(&cursor_rules)
.map(|mut entries| entries.next().is_some())
.unwrap_or(false);
if has_entry {
found.push(".cursor/rules/".to_string());
}
}
found.sort();
found
}
fn detect_package_managers() -> Vec<String> {
let path_value = std::env::var("PATH").unwrap_or_default();
PACKAGE_MANAGERS
.iter()
.filter(|(binary, _)| !tirith_core::path_audit::which_all(binary, &path_value).is_empty())
.map(|(_, label)| (*label).to_string())
.collect()
}
fn detect_lockfiles(root: &Path) -> Vec<String> {
LOCKFILES
.iter()
.filter(|(rel, _)| root.join(rel).is_file())
.map(|(_, label)| (*label).to_string())
.collect()
}
fn detect_ci(root: &Path) -> bool {
let workflows = root.join(".github").join("workflows");
let entries = match std::fs::read_dir(&workflows) {
Ok(e) => e,
Err(_) => return false,
};
entries.flatten().any(|entry| {
if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
return false;
}
entry
.path()
.extension()
.and_then(|e| e.to_str())
.map(|ext| ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml"))
.unwrap_or(false)
})
}
fn home_base() -> Option<PathBuf> {
#[cfg(unix)]
let env_home = std::env::var_os("HOME");
#[cfg(not(unix))]
let env_home = std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME"));
env_home
.filter(|h| !h.is_empty() && Path::new(h).is_absolute())
.map(PathBuf::from)
.or_else(home::home_dir)
.filter(|p| p.is_absolute())
}
fn detect_mcp_configs(root: &Path) -> Vec<String> {
let mut found: Vec<String> = MCP_CONFIG_RELATIVE_PATHS
.iter()
.filter(|rel| root.join(rel).is_file())
.map(|rel| (*rel).to_string())
.collect();
if let Some(home) = home_base() {
let windsurf = home
.join(".codeium")
.join("windsurf")
.join("mcp_config.json");
if windsurf.is_file() {
found.push(windsurf.display().to_string());
}
}
found.sort();
found
}
fn detect_tirith_state(cwd: &Path, detected_shell: &str) -> TirithState {
let (_profile, hook_installed) =
crate::cli::doctor::check_shell_profile(detected_shell, "tirith: onboard:");
let cwd_str = cwd.display().to_string();
let policy_path = tirith_core::policy::discover_local_policy_path(Some(&cwd_str));
TirithState {
hook_installed,
policy_present: policy_path.is_some(),
policy_path: policy_path.map(|p| p.display().to_string()),
}
}
struct RecommendationSignals<'a> {
mode: Option<&'a str>,
ai_config_count: usize,
mcp_config_count: usize,
ci_detected: bool,
}
fn recommend_template(signals: &RecommendationSignals) -> (PolicyTemplate, String) {
match signals.mode {
Some("ai-agent-heavy") => {
return (
PolicyTemplate::AiAgentHeavy,
"requested --ai-agent-heavy".to_string(),
);
}
Some("team") => {
return (
PolicyTemplate::Startup,
"requested --team (balanced shared defaults for a human team)".to_string(),
);
}
Some("repo") => {
if signals.ci_detected {
return (
PolicyTemplate::CiStrict,
"requested --repo and a .github/workflows CI pipeline is present".to_string(),
);
}
return (
PolicyTemplate::Individual,
"requested --repo with no CI pipeline detected".to_string(),
);
}
_ => {}
}
if signals.ai_config_count >= 2 || signals.mcp_config_count >= 1 {
return (
PolicyTemplate::AiAgentHeavy,
format!(
"{} AI-config file(s) and {} MCP config(s) detected — an AI-agent-heavy environment",
signals.ai_config_count, signals.mcp_config_count
),
);
}
if signals.ci_detected {
return (
PolicyTemplate::CiStrict,
"a .github/workflows CI pipeline is present".to_string(),
);
}
(
PolicyTemplate::Individual,
"no CI or heavy AI-agent signals — sensible single-developer defaults".to_string(),
)
}
fn build_next_actions(tirith: &TirithState, template: PolicyTemplate) -> Vec<String> {
let mut actions = Vec::new();
if !tirith.hook_installed {
actions.push(
"run `tirith init` and add the printed line to your shell profile to install the hook"
.to_string(),
);
}
if !tirith.policy_present {
actions.push(format!(
"run `tirith policy init --template {}`",
template.canonical_name()
));
}
if actions.is_empty() {
actions.push(
"tirith is already set up here — run `tirith doctor` to confirm protection status"
.to_string(),
);
}
actions
}
fn print_human(report: &OnboardReport) {
println!("tirith onboard — environment detection");
println!(" directory: {}", report.cwd);
if let Some(root) = &report.repo_root {
println!(" repo root: {root}");
}
if report.requested_mode != "auto" {
println!(" mode bias: --{}", report.requested_mode);
}
println!(" shell: {}", report.detected_shell);
println!(" IDE configs: {}", fmt_list(&report.ide_configs));
println!(" AI configs: {}", fmt_list(&report.ai_config_files));
println!(" pkg mgrs: {}", fmt_list(&report.package_managers));
println!(" lockfiles: {}", fmt_list(&report.lockfiles));
println!(
" CI: {}",
if report.ci_detected {
".github/workflows present"
} else {
"none"
}
);
println!(" MCP configs: {}", fmt_list(&report.mcp_configs));
println!();
println!("tirith status");
println!(
" shell hook: {}",
if report.tirith.hook_installed {
"installed"
} else {
"not installed"
}
);
match &report.tirith.policy_path {
Some(p) => println!(" policy: {p}"),
None => println!(" policy: none"),
}
println!();
println!(
"Recommended policy template: {}",
report.recommended_template
);
println!(" why: {}", report.recommendation_reason);
println!();
println!("Next steps:");
for (i, action) in report.next_actions.iter().enumerate() {
println!(" {}. {action}", i + 1);
}
}
fn fmt_list(items: &[String]) -> String {
if items.is_empty() {
"(none)".to_string()
} else {
items.join(", ")
}
}
fn apply_actions(report: &OnboardReport) -> i32 {
apply_actions_with_interactivity(report, is_tty_pair())
}
fn apply_actions_with_interactivity(report: &OnboardReport, interactive: bool) -> i32 {
println!();
let needs_hook = !report.tirith.hook_installed;
let needs_policy = !report.tirith.policy_present;
if !needs_hook && !needs_policy {
println!("tirith onboard: no actions applied.");
return 0;
}
if !interactive {
eprintln!("tirith onboard --apply: not an interactive terminal — refusing to act.");
eprintln!(" Re-run interactively to apply, or perform these steps yourself:");
for action in &report.next_actions {
eprintln!(" - {action}");
}
return 1;
}
let mut performed = 0;
let mut failed = false;
if !report.tirith.hook_installed
&& confirm_stdin("Show the `tirith init` shell-hook line to install?")
{
let rc = crate::cli::init::run(None, false);
if rc == 0 {
println!(
" Add the line above to your shell profile, then restart your shell or `source` it."
);
performed += 1;
} else {
eprintln!(" `tirith init` failed (exit code {rc}).");
failed = true;
}
}
if report.tirith.policy_present {
println!(
" A policy already exists at {} — leaving it untouched.",
report.tirith.policy_path.as_deref().unwrap_or("<unknown>")
);
} else if confirm_stdin(&format!(
"Run `tirith policy init --template {}`?",
report.recommended_template
)) {
let rc = crate::cli::policy::init(false, false, Some(&report.recommended_template));
if rc == 0 {
performed += 1;
} else {
eprintln!(" `tirith policy init` failed (exit code {rc}).");
failed = true;
}
}
if performed == 0 {
println!("tirith onboard: no actions applied.");
} else {
println!("tirith onboard: applied {performed} action(s).");
}
if failed {
1
} else {
0
}
}
fn confirm_stdin(prompt: &str) -> bool {
use std::io::Write;
eprint!("{prompt} [y/N] ");
let _ = std::io::stderr().flush();
let mut input = String::new();
match std::io::stdin().read_line(&mut input) {
Ok(_) => matches!(input.trim(), "y" | "Y" | "yes" | "Yes"),
Err(e) => {
eprintln!("tirith onboard: could not read confirmation input: {e}");
false
}
}
}
fn is_tty_pair() -> bool {
is_terminal::is_terminal(std::io::stdin()) && is_terminal::is_terminal(std::io::stderr())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recommend_explicit_modes_win() {
let ai = recommend_template(&RecommendationSignals {
mode: Some("ai-agent-heavy"),
ai_config_count: 0,
mcp_config_count: 0,
ci_detected: false,
});
assert_eq!(ai.0, PolicyTemplate::AiAgentHeavy);
let team = recommend_template(&RecommendationSignals {
mode: Some("team"),
ai_config_count: 0,
mcp_config_count: 0,
ci_detected: false,
});
assert_eq!(team.0, PolicyTemplate::Startup);
let repo_ci = recommend_template(&RecommendationSignals {
mode: Some("repo"),
ai_config_count: 0,
mcp_config_count: 0,
ci_detected: true,
});
assert_eq!(repo_ci.0, PolicyTemplate::CiStrict);
let repo_plain = recommend_template(&RecommendationSignals {
mode: Some("repo"),
ai_config_count: 5,
mcp_config_count: 5,
ci_detected: false,
});
assert_eq!(
repo_plain.0,
PolicyTemplate::Individual,
"an explicit --repo bias must not be overridden by auto AI-agent signals"
);
}
#[test]
fn recommend_auto_prioritizes_ai_then_ci_then_individual() {
let ai = recommend_template(&RecommendationSignals {
mode: None,
ai_config_count: 2,
mcp_config_count: 0,
ci_detected: true,
});
assert_eq!(ai.0, PolicyTemplate::AiAgentHeavy);
let mcp = recommend_template(&RecommendationSignals {
mode: None,
ai_config_count: 0,
mcp_config_count: 1,
ci_detected: false,
});
assert_eq!(mcp.0, PolicyTemplate::AiAgentHeavy);
let ci = recommend_template(&RecommendationSignals {
mode: None,
ai_config_count: 1,
mcp_config_count: 0,
ci_detected: true,
});
assert_eq!(ci.0, PolicyTemplate::CiStrict);
let individual = recommend_template(&RecommendationSignals {
mode: None,
ai_config_count: 0,
mcp_config_count: 0,
ci_detected: false,
});
assert_eq!(individual.0, PolicyTemplate::Individual);
}
#[test]
fn next_actions_reflect_install_state() {
let fresh = build_next_actions(
&TirithState {
hook_installed: false,
policy_present: false,
policy_path: None,
},
PolicyTemplate::Individual,
);
assert!(fresh.iter().any(|a| a.contains("tirith init")));
assert!(fresh
.iter()
.any(|a| a.contains("tirith policy init --template individual")));
let done = build_next_actions(
&TirithState {
hook_installed: true,
policy_present: true,
policy_path: Some("/repo/.tirith/policy.yaml".to_string()),
},
PolicyTemplate::CiStrict,
);
assert_eq!(done.len(), 1);
assert!(done[0].contains("already set up"));
}
fn report_with_state(hook_installed: bool, policy_present: bool) -> OnboardReport {
OnboardReport {
schema_version: ONBOARD_SCHEMA_VERSION,
cwd: ".".to_string(),
repo_root: None,
requested_mode: "auto".to_string(),
detected_shell: "bash".to_string(),
ide_configs: vec![],
ai_config_files: vec![],
package_managers: vec![],
lockfiles: vec![],
ci_detected: false,
mcp_configs: vec![],
tirith: TirithState {
hook_installed,
policy_present,
policy_path: policy_present.then(|| "/repo/.tirith/policy.yaml".to_string()),
},
recommended_template: "individual".to_string(),
recommendation_reason: "test".to_string(),
next_actions: vec!["do a thing".to_string()],
}
}
#[test]
fn apply_actions_noop_when_already_configured_returns_zero() {
let report = report_with_state(true, true);
assert_eq!(
apply_actions_with_interactivity(&report, false),
0,
"an already-configured repo has nothing to apply — must exit 0 even non-interactively"
);
}
#[test]
fn apply_actions_noninteractive_with_work_returns_one() {
let report = report_with_state(false, true);
assert_eq!(
apply_actions_with_interactivity(&report, false),
1,
"a non-interactive --apply with work to do must refuse (exit 1)"
);
}
#[test]
fn ai_config_basenames_are_canonical() {
use tirith_core::rules::aifile;
for name in AI_CONFIG_BASENAMES {
assert!(
aifile::is_ai_config_file(Path::new(name)),
"{name:?} is in AI_CONFIG_BASENAMES but is NOT recognised by the canonical \
is_ai_config_file — the onboard detector has drifted from the product's set"
);
}
}
#[test]
fn detect_ai_config_recognizes_broader_signals() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::write(root.join("copilot-instructions.md"), "# copilot\n").unwrap();
std::fs::write(root.join(".clinerules"), "rules\n").unwrap();
std::fs::write(root.join(".clinerules-security"), "themed\n").unwrap();
let found = detect_ai_config(root);
assert!(
found.iter().any(|f| f == "copilot-instructions.md"),
"copilot-instructions.md must be detected as AI config, got: {found:?}"
);
assert!(
found.iter().any(|f| f == ".clinerules"),
".clinerules must be detected as AI config, got: {found:?}"
);
assert!(
found.iter().any(|f| f == ".clinerules-security"),
"themed .clinerules-* must be detected as AI config, got: {found:?}"
);
let (template, _why) = recommend_template(&RecommendationSignals {
mode: None,
ai_config_count: found.len(),
mcp_config_count: 0,
ci_detected: false,
});
assert_eq!(
template,
PolicyTemplate::AiAgentHeavy,
"a repo with multiple broader AI-config signals must recommend ai-agent-heavy"
);
}
#[test]
fn detect_ai_config_is_sorted_for_stable_json() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::write(root.join("CLAUDE.md"), "x\n").unwrap();
std::fs::write(root.join(".cursorrules"), "x\n").unwrap();
std::fs::write(root.join("AGENTS.md"), "x\n").unwrap();
std::fs::write(root.join(".clinerules-security"), "x\n").unwrap();
std::fs::write(root.join(".clinerules-perf"), "x\n").unwrap();
std::fs::write(root.join(".roorules-review"), "x\n").unwrap();
std::fs::create_dir_all(root.join(".claude")).unwrap();
std::fs::create_dir_all(root.join(".cursor").join("rules")).unwrap();
std::fs::write(root.join(".cursor").join("rules").join("a.mdc"), "rule\n").unwrap();
let found = detect_ai_config(root);
let mut sorted = found.clone();
sorted.sort();
assert_eq!(
found, sorted,
"ai_config_files must be returned in sorted order for deterministic JSON, got: {found:?}"
);
assert!(found.iter().any(|f| f == "CLAUDE.md"));
assert!(found.iter().any(|f| f == ".clinerules-security"));
assert!(found.iter().any(|f| f == ".claude/"));
assert!(found.iter().any(|f| f == ".cursor/rules/"));
}
#[test]
fn detect_mcp_configs_is_sorted_for_stable_json() {
let repo = tempfile::tempdir().expect("repo");
let root = repo.path();
std::fs::write(root.join("mcp.json"), "{}\n").unwrap();
std::fs::create_dir_all(root.join(".vscode")).unwrap();
std::fs::write(root.join(".vscode").join("mcp.json"), "{}\n").unwrap();
std::fs::create_dir_all(root.join(".cursor")).unwrap();
std::fs::write(root.join(".cursor").join("mcp.json"), "{}\n").unwrap();
let home = tempfile::tempdir().expect("home");
let _guard = HomeGuard::set(home.path());
let found = detect_mcp_configs(root);
let mut sorted = found.clone();
sorted.sort();
assert_eq!(
found, sorted,
"mcp_configs must be returned in sorted order for deterministic JSON, got: {found:?}"
);
assert!(
found.len() >= 3,
"expected the three planted repo-local MCP configs, got: {found:?}"
);
}
#[test]
fn detect_ai_config_finds_github_copilot_instructions() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path();
std::fs::create_dir_all(root.join(".github")).unwrap();
std::fs::write(
root.join(".github").join("copilot-instructions.md"),
"# copilot\n",
)
.unwrap();
let found = detect_ai_config(root);
assert!(
found.iter().any(|f| f == ".github/copilot-instructions.md"),
".github/copilot-instructions.md must be detected, got: {found:?}"
);
}
#[test]
fn detect_ci_requires_regular_file_not_directory() {
let with_file = tempfile::tempdir().expect("tempdir");
let wf = with_file.path().join(".github").join("workflows");
std::fs::create_dir_all(&wf).unwrap();
std::fs::write(wf.join("ci.yml"), "on: push\n").unwrap();
assert!(
detect_ci(with_file.path()),
"a real *.yml workflow FILE must be detected as CI"
);
let dir_only = tempfile::tempdir().expect("tempdir");
let wf2 = dir_only.path().join(".github").join("workflows");
std::fs::create_dir_all(wf2.join("pipeline.yaml")).unwrap();
assert!(
!detect_ci(dir_only.path()),
"a DIRECTORY named *.yaml under workflows must NOT be counted as CI"
);
}
#[test]
fn detect_ci_absent_workflows_dir_is_not_ci() {
let empty = tempfile::tempdir().expect("tempdir");
assert!(
!detect_ci(empty.path()),
"no .github/workflows dir must read as non-CI"
);
}
use crate::cli::test_harness::{EnvGuard, ENV_LOCK};
struct HomeGuard {
home: Option<EnvGuard>,
userprofile: Option<EnvGuard>,
lock: Option<tirith_test_support::GlobalStateGuard>,
}
impl HomeGuard {
fn set(dir: &Path) -> Self {
let lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let home = EnvGuard::set("HOME", dir);
let userprofile = EnvGuard::set("USERPROFILE", dir);
Self {
home: Some(home),
userprofile: Some(userprofile),
lock: Some(lock),
}
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
drop(self.home.take());
drop(self.userprofile.take());
drop(self.lock.take());
}
}
#[test]
fn home_base_resolves_from_env() {
let dir = tempfile::tempdir().expect("tempdir");
let _guard = HomeGuard::set(dir.path());
assert_eq!(
home_base(),
Some(dir.path().to_path_buf()),
"home_base must honor the HOME/USERPROFILE env override"
);
}
#[test]
fn home_base_rejects_relative_home() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _home = EnvGuard::set("HOME", Path::new("relative-home"));
let _userprofile = EnvGuard::set("USERPROFILE", Path::new("relative-home"));
let base = home_base();
assert_ne!(
base.as_deref(),
Some(Path::new("relative-home")),
"home_base must not return a relative HOME/USERPROFILE override"
);
if let Some(p) = &base {
assert!(
p.is_absolute(),
"home_base fallback must be absolute, got {p:?}"
);
}
}
#[test]
fn home_base_treats_empty_env_as_unset() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let _home = EnvGuard::set("HOME", Path::new(""));
let _userprofile = EnvGuard::set("USERPROFILE", Path::new(""));
let base = home_base();
assert_ne!(
base.as_deref(),
Some(Path::new("")),
"home_base must not return an empty path for an empty HOME/USERPROFILE"
);
if let Some(p) = &base {
assert!(
!p.as_os_str().is_empty(),
"home_base fallback must be a non-empty path, got {p:?}"
);
}
}
#[test]
fn detect_mcp_configs_uses_env_home_for_windsurf() {
let repo = tempfile::tempdir().expect("repo");
let home_absent = tempfile::tempdir().expect("home_absent");
{
let _guard = HomeGuard::set(home_absent.path());
let found = detect_mcp_configs(repo.path());
assert!(
found.is_empty(),
"an isolated home with no windsurf config must yield 0 MCP configs \
(host's real ~/.codeium must not leak in), got: {found:?}"
);
}
let home_present = tempfile::tempdir().expect("home_present");
let windsurf_dir = home_present.path().join(".codeium").join("windsurf");
std::fs::create_dir_all(&windsurf_dir).unwrap();
let windsurf_cfg = windsurf_dir.join("mcp_config.json");
std::fs::write(&windsurf_cfg, "{}\n").unwrap();
{
let _guard = HomeGuard::set(home_present.path());
let found = detect_mcp_configs(repo.path());
assert!(
found
.iter()
.any(|f| f == &windsurf_cfg.display().to_string()),
"a windsurf MCP config under the isolated home must be detected, got: {found:?}"
);
}
}
}