use std::path::Path;
use std::process::Command;
use super::common::{CommandOutput, copy_fixture, fallow_bin};
const HINT: &str =
r#"<claude-code-hint v="1" type="plugin" value="fallow@claude-plugins-official" />"#;
const AMBIENT_ENV: &[&str] = &[
"CI",
"GITHUB_ACTIONS",
"GITLAB_CI",
"CLAUDECODE",
"CLAUDE_CODE_CHILD_SESSION",
"CLAUDE_CONFIG_DIR",
"FALLOW_CLAUDE_CODE_HINT",
"FALLOW_SUGGESTIONS",
"FALLOW_FORMAT",
"FALLOW_QUIET",
];
fn run(root: &Path, home: &Path, args: &[&str], env: &[(&str, &str)]) -> CommandOutput {
let mut cmd = Command::new(fallow_bin());
for key in AMBIENT_ENV {
cmd.env_remove(key);
}
cmd.env("RUST_LOG", "")
.env("NO_COLOR", "1")
.env("HOME", home)
.env("USERPROFILE", home)
.env("CLAUDE_CONFIG_DIR", home.join(".claude"))
.env("FALLOW_UPDATE_CHECK", "off")
.env("FALLOW_TELEMETRY", "off")
.envs(env.iter().copied())
.arg("dead-code")
.arg("--root")
.arg(root)
.arg("--no-cache")
.args(args);
let output = cmd.output().expect("run fallow binary");
CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
code: output.status.code().unwrap_or(-1),
}
}
fn hint_count(text: &str) -> usize {
text.lines().filter(|line| line.trim() == HINT).count()
}
fn assert_no_hint(output: &CommandOutput, case: &str) {
assert_eq!(hint_count(&output.stdout), 0, "{case}: hint on stdout");
assert_eq!(
hint_count(&output.stderr),
0,
"{case}: hint on stderr:\n{}",
output.stderr
);
}
#[test]
fn claude_code_session_gets_one_hint_line_on_stderr() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
let output = run(project.path(), home.path(), &[], &[("CLAUDECODE", "1")]);
assert!(output.code == 0 || output.code == 1, "{}", output.stderr);
assert_eq!(hint_count(&output.stderr), 1, "stderr:\n{}", output.stderr);
assert_eq!(
hint_count(&output.stdout),
0,
"the hint never goes to stdout"
);
}
#[test]
fn claude_code_child_session_marker_also_enables_the_hint() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
let output = run(
project.path(),
home.path(),
&[],
&[("CLAUDE_CODE_CHILD_SESSION", "1")],
);
assert_eq!(hint_count(&output.stderr), 1, "stderr:\n{}", output.stderr);
}
#[test]
fn no_hint_without_a_claude_code_session() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
let output = run(project.path(), home.path(), &[], &[]);
assert_no_hint(&output, "no session");
let output = run(project.path(), home.path(), &[], &[("CLAUDECODE", "")]);
assert_no_hint(&output, "empty CLAUDECODE");
}
#[test]
fn no_hint_for_any_machine_readable_format() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
for format in [
"json",
"sarif",
"codeclimate",
"compact",
"markdown",
"badge",
] {
let output = run(
project.path(),
home.path(),
&["--format", format],
&[("CLAUDECODE", "1")],
);
assert_no_hint(&output, format);
}
}
#[test]
fn no_hint_under_quiet() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
let output = run(
project.path(),
home.path(),
&["--quiet"],
&[("CLAUDECODE", "1")],
);
assert_no_hint(&output, "--quiet");
}
#[test]
fn no_hint_in_ci() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
let output = run(
project.path(),
home.path(),
&[],
&[("CLAUDECODE", "1"), ("CI", "true")],
);
assert_no_hint(&output, "CI");
}
#[test]
fn opt_out_values_silence_the_hint() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
for value in ["off", "0", "false", "no", "disabled", "OFF"] {
let output = run(
project.path(),
home.path(),
&[],
&[("CLAUDECODE", "1"), ("FALLOW_CLAUDE_CODE_HINT", value)],
);
assert_no_hint(&output, value);
}
let output = run(
project.path(),
home.path(),
&[],
&[("CLAUDECODE", "1"), ("FALLOW_SUGGESTIONS", "off")],
);
assert_no_hint(&output, "FALLOW_SUGGESTIONS=off");
}
#[test]
fn no_hint_when_the_project_enables_the_fallow_plugin() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
let claude = project.path().join(".claude");
std::fs::create_dir_all(&claude).expect("create .claude");
std::fs::write(
claude.join("settings.json"),
r#"{"enabledPlugins":{"fallow@fallow-skills":true}}"#,
)
.expect("write settings");
let output = run(project.path(), home.path(), &[], &[("CLAUDECODE", "1")]);
assert_no_hint(&output, "project enabledPlugins");
}
#[test]
fn no_hint_when_the_repository_root_enables_the_plugin_for_a_nested_root() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
std::fs::create_dir_all(project.path().join(".git")).expect("create .git");
let claude = project.path().join(".claude");
std::fs::create_dir_all(&claude).expect("create .claude");
std::fs::write(
claude.join("settings.json"),
r#"{"enabledPlugins":{"fallow@fallow-skills":true}}"#,
)
.expect("write settings");
let nested = project.path().join("src");
let output = run(&nested, home.path(), &[], &[("CLAUDECODE", "1")]);
assert_no_hint(
&output,
"repository-root enabledPlugins with a nested --root",
);
}
#[test]
fn no_hint_on_an_input_error() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
let output = run(
project.path(),
home.path(),
&["--config", "missing-config.json"],
&[("CLAUDECODE", "1")],
);
assert_eq!(output.code, 2, "{}", output.stderr);
assert_no_hint(&output, "exit 2");
}
#[test]
fn no_hint_when_the_plugin_is_installed_for_the_user() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
let plugins = home.path().join(".claude/plugins");
std::fs::create_dir_all(&plugins).expect("create plugins dir");
std::fs::write(
plugins.join("installed_plugins.json"),
r#"{"version":2,"plugins":{"fallow@fallow-skills":[{"scope":"user"}]}}"#,
)
.expect("write installed_plugins");
let output = run(project.path(), home.path(), &[], &[("CLAUDECODE", "1")]);
assert_no_hint(&output, "user-scope install");
}
#[test]
fn no_hint_from_the_agent_setup_commands() {
let project = copy_fixture("basic-project");
let home = tempfile::tempdir().expect("temp home");
for args in [
["agent", "status"],
["agent", "uninstall"],
["hooks", "status"],
] {
let mut cmd = Command::new(fallow_bin());
for key in AMBIENT_ENV {
cmd.env_remove(key);
}
let output = cmd
.env("RUST_LOG", "")
.env("NO_COLOR", "1")
.env("HOME", home.path())
.env("USERPROFILE", home.path())
.env("CLAUDE_CONFIG_DIR", home.path().join(".claude"))
.env("FALLOW_UPDATE_CHECK", "off")
.env("FALLOW_TELEMETRY", "off")
.env("CLAUDECODE", "1")
.args(args)
.arg("--root")
.arg(project.path())
.output()
.expect("run fallow binary");
let output = CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
code: output.status.code().unwrap_or(-1),
};
assert!(
output.code == 0 || output.code == 1,
"{args:?}: {}",
output.stderr
);
assert_no_hint(&output, &args.join(" "));
}
}