use crate::plugins::PluginRegistry;
use crate::segments::builder::build_lines;
use crate::{
cli, config, detect_terminal_width, presets, run_lines_with_context, runtime, theme, RunContext,
};
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
fn no_color_env(name: &str) -> bool {
std::env::var_os(name).is_some_and(|v| !v.is_empty())
}
fn force_color_env(name: &str) -> bool {
let Ok(v) = std::env::var(name) else {
return false;
};
!matches!(v.as_str(), "" | "0" | "false" | "off")
}
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct CliEnv {
pub linesmith_config: Option<String>,
pub xdg_config_home: Option<String>,
pub home: Option<String>,
pub no_color: bool,
pub force_color: bool,
pub colorterm: Option<String>,
pub term: Option<String>,
pub terminal_width: Option<u16>,
pub color_capability: Option<theme::Capability>,
pub cwd: Option<std::path::PathBuf>,
pub log_level_env: Option<String>,
}
impl CliEnv {
#[must_use]
pub fn from_process() -> Self {
Self {
linesmith_config: std::env::var("LINESMITH_CONFIG").ok(),
xdg_config_home: std::env::var("XDG_CONFIG_HOME").ok(),
home: std::env::var("HOME").ok(),
no_color: no_color_env("NO_COLOR"),
force_color: force_color_env("FORCE_COLOR"),
colorterm: std::env::var("COLORTERM").ok(),
term: std::env::var("TERM").ok(),
terminal_width: None,
color_capability: None,
cwd: std::env::current_dir().ok(),
log_level_env: std::env::var(crate::logging::ENV_VAR).ok(),
}
}
#[must_use]
pub fn for_tests() -> Self {
Self {
linesmith_config: None,
xdg_config_home: None,
home: None,
no_color: false,
force_color: false,
colorterm: None,
term: None,
terminal_width: Some(200),
color_capability: Some(theme::Capability::None),
cwd: None,
log_level_env: None,
}
}
}
pub fn cli_main<A>(
args: A,
stdin: impl Read,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
env: &CliEnv,
) -> u8
where
A: IntoIterator,
A::Item: Into<std::ffi::OsString>,
{
crate::logging::apply(env.log_level_env.as_deref(), stderr);
let action = match cli::parse(args) {
Ok(a) => a,
Err(err) => {
let _ = writeln!(stderr, "linesmith: {err}");
let _ = writeln!(stderr, "Try --help for usage.");
return 2;
}
};
match action {
cli::Action::Help => {
let _ = write!(stdout, "{}", cli::HELP);
0
}
cli::Action::Version => {
let _ = writeln!(stdout, "linesmith {}", env!("CARGO_PKG_VERSION"));
0
}
cli::Action::ThemesList => themes_list(stdout, stderr, env),
cli::Action::PresetsList => presets_list(stdout),
cli::Action::PresetsApply {
name,
force,
config,
} => presets_apply(&name, force, config, stdin, stdout, stderr, env),
cli::Action::Init { config, no_doctor } => {
init_action(config, no_doctor, stdin, stdout, stderr, env)
}
cli::Action::Doctor { plain, config } => doctor_action(plain, config, stdout, stderr),
cli::Action::Run(args) => run_cli(args, stdin, stdout, stderr, env),
}
}
fn doctor_action(
plain: bool,
config_override: Option<PathBuf>,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
) -> u8 {
let env = crate::doctor::DoctorEnv::from_process(config_override);
doctor_action_with_env(env, plain, stdout, stderr)
}
fn doctor_action_with_env(
env: crate::doctor::DoctorEnv,
plain: bool,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
) -> u8 {
let report = crate::doctor::build_report(&env);
let mode = if plain {
crate::doctor::RenderMode::Plain
} else {
crate::doctor::RenderMode::Default
};
if let Err(err) = crate::doctor::render(stdout, &report, mode) {
let _ = writeln!(stderr, "linesmith: doctor: {err}");
}
report.exit_code()
}
fn themes_list(stdout: &mut dyn Write, stderr: &mut dyn Write, env: &CliEnv) -> u8 {
let registry = build_theme_registry(env, stderr);
for rt in registry.iter() {
let source = match &rt.source {
theme::ThemeSource::BuiltIn => "built-in".to_string(),
theme::ThemeSource::UserFile(p) => p.display().to_string(),
_ => "unknown source".to_string(),
};
let _ = writeln!(stdout, "{}\t{}", rt.theme.name(), source);
}
0
}
fn presets_list(stdout: &mut dyn Write) -> u8 {
for name in presets::names() {
let _ = writeln!(stdout, "{name}");
}
0
}
fn presets_apply(
name: &str,
force: bool,
config_override: Option<PathBuf>,
stdin: impl Read,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
env: &CliEnv,
) -> u8 {
let Some(body) = presets::body(name) else {
let _ = writeln!(stderr, "linesmith: unknown preset '{name}'");
let _ = writeln!(stderr, "available presets:");
for known in presets::names() {
let _ = writeln!(stderr, " {known}");
}
return 1;
};
let Some(resolved) = resolve_writable_config_path(config_override, env, stderr) else {
return 1;
};
let policy = OverwritePolicy::presets(force);
let with_schema = config::with_schema_directive(body);
if let Err(code) = write_config_with_backup(&with_schema, &resolved.path, policy, stdin, stderr)
{
return code;
}
let _ = writeln!(
stdout,
"wrote preset '{name}' to {}",
resolved.path.display()
);
0
}
fn resolve_writable_config_path(
config_override: Option<PathBuf>,
env: &CliEnv,
stderr: &mut dyn Write,
) -> Option<config::ConfigPath> {
match config::resolve_config_path(
config_override,
env.linesmith_config.as_deref(),
env.xdg_config_home.as_deref(),
env.home.as_deref(),
) {
Some(p) => Some(p),
None => {
let _ = writeln!(
stderr,
"linesmith: cannot resolve a config path (set XDG_CONFIG_HOME or HOME)"
);
None
}
}
}
#[derive(Debug, Clone, Copy)]
struct OverwritePolicy {
skip_prompt: bool,
clobber_backup: bool,
}
impl OverwritePolicy {
fn presets(force: bool) -> Self {
Self {
skip_prompt: force,
clobber_backup: force,
}
}
fn init() -> Self {
Self {
skip_prompt: false,
clobber_backup: true,
}
}
}
fn write_config_with_backup(
body: &str,
path: &Path,
policy: OverwritePolicy,
stdin: impl Read,
stderr: &mut dyn Write,
) -> Result<bool, u8> {
let backup = path.with_extension("toml.bak");
let mut backup_written = false;
if path.exists() {
if !policy.skip_prompt && !confirm_overwrite(path, stdin, stderr) {
let _ = writeln!(stderr, "linesmith: aborted; config.toml unchanged");
return Err(1);
}
let mut clobbered_prior_bak = false;
if backup.exists() {
if !policy.clobber_backup {
let _ = writeln!(
stderr,
"linesmith: {} already exists; rerun with --force to replace it",
backup.display()
);
return Err(1);
}
if let Err(e) = std::fs::remove_file(&backup) {
let _ = writeln!(
stderr,
"linesmith: could not remove existing backup {}: {e}",
backup.display()
);
return Err(1);
}
clobbered_prior_bak = true;
}
if let Err(e) = std::fs::rename(path, &backup) {
let _ = writeln!(
stderr,
"linesmith: could not back up {} to {}: {e}",
path.display(),
backup.display()
);
if clobbered_prior_bak {
let _ = writeln!(
stderr,
"linesmith: note: the previous {} was removed just before this failed rename; it is gone",
backup.display()
);
}
return Err(1);
}
let _ = writeln!(
stderr,
"linesmith: backed up previous config to {}",
backup.display()
);
backup_written = true;
} else if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
let _ = writeln!(
stderr,
"linesmith: could not create {}: {e}",
parent.display()
);
return Err(1);
}
}
if let Err(e) = std::fs::write(path, body) {
let _ = writeln!(stderr, "linesmith: write {}: {e}", path.display());
if backup_written {
let _ = writeln!(
stderr,
"linesmith: your previous config is preserved at {}",
backup.display()
);
}
return Err(1);
}
Ok(backup_written)
}
fn confirm_overwrite(path: &Path, stdin: impl Read, stderr: &mut dyn Write) -> bool {
let _ = write!(stderr, "overwrite {}? [y/N] ", path.display());
let _ = stderr.flush();
let mut line = String::new();
let mut reader = BufReader::new(stdin);
if reader.read_line(&mut line).is_err() {
return false;
}
parse_confirmation(&line)
}
fn parse_confirmation(input: &str) -> bool {
matches!(input.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
#[derive(Debug, Clone)]
struct InitChoices {
preset: String,
theme: String,
}
fn init_action(
config_override: Option<PathBuf>,
no_doctor: bool,
stdin: impl Read,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
env: &CliEnv,
) -> u8 {
let preset_names: Vec<&'static str> = presets::names().collect();
let registry = build_theme_registry(env, stderr);
let theme_names: Vec<String> = registry
.iter()
.map(|t| t.theme.name().to_string())
.collect();
let choices = match prompt_for_init_choices(&preset_names, &theme_names) {
Ok(c) => c,
Err(err) => {
let _ = writeln!(stderr, "linesmith: init: {err}");
let _ = writeln!(
stderr,
"linesmith: if a terminal isn't attached, use 'linesmith presets apply <name>' instead",
);
return 1;
}
};
init_with_choices(
&choices,
no_doctor,
config_override,
stdin,
stdout,
stderr,
env,
)
}
fn prompt_for_init_choices(
presets: &[&str],
themes: &[String],
) -> Result<InitChoices, dialoguer::Error> {
use dialoguer::{theme::ColorfulTheme, Select};
let theme = ColorfulTheme::default();
let preset_idx = Select::with_theme(&theme)
.with_prompt("preset")
.items(presets)
.default(0)
.interact()?;
let theme_idx = Select::with_theme(&theme)
.with_prompt("theme")
.items(themes)
.default(0)
.interact()?;
Ok(InitChoices {
preset: presets[preset_idx].to_string(),
theme: themes[theme_idx].clone(),
})
}
fn init_with_choices(
choices: &InitChoices,
no_doctor: bool,
config_override: Option<PathBuf>,
stdin: impl Read,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
env: &CliEnv,
) -> u8 {
let Some(body) = presets::body(&choices.preset) else {
let _ = writeln!(stderr, "linesmith: unknown preset '{}'", choices.preset);
return 1;
};
let composed = config::with_schema_directive(&compose_init_body(body, &choices.theme));
let Some(resolved) = resolve_writable_config_path(config_override, env, stderr) else {
return 1;
};
let policy = OverwritePolicy::init();
if let Err(code) = write_config_with_backup(&composed, &resolved.path, policy, stdin, stderr) {
return code;
}
let snippet_path = resolved.explicit.then(|| resolved.path.clone());
let _ = writeln!(stdout, "wrote config.toml to {}", resolved.path.display());
warn_if_path_needs_user_edit(snippet_path.as_deref(), stderr);
let _ = writeln!(stdout);
let _ = writeln!(stdout, "Add this to your Claude Code settings.json:");
let _ = writeln!(stdout, "(merge into the existing top-level object)");
let _ = writeln!(stdout);
let _ = writeln!(stdout, " \"statusLine\": {{");
let _ = writeln!(stdout, " \"type\": \"command\",");
let _ = writeln!(stdout, " \"command\": {},", json_command(&snippet_path));
let _ = writeln!(stdout, " \"padding\": 0");
let _ = writeln!(stdout, " }}");
if no_doctor {
return 0;
}
let _ = writeln!(stdout);
let _ = writeln!(stdout, "Running doctor to verify your setup...");
let _ = writeln!(stdout);
doctor_action(false, Some(resolved.path), stdout, stderr)
}
fn json_command(explicit_path: &Option<PathBuf>) -> String {
let escape = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
match explicit_path {
Some(p) => format!("\"linesmith --config {}\"", escape(&p.to_string_lossy())),
None => "\"linesmith\"".to_string(),
}
}
fn warn_if_path_needs_user_edit(explicit_path: Option<&Path>, stderr: &mut dyn Write) {
let Some(p) = explicit_path else { return };
let s = p.to_str();
let non_utf8 = s.is_none();
let leading_dash = s.is_some_and(|s| s.starts_with('-'));
let needs_quoting = s.is_some_and(|s| s.chars().any(needs_shell_quoting));
if non_utf8 {
let _ = writeln!(
stderr,
"linesmith: warning: --config path contains non-UTF-8 bytes; the emitted snippet uses Unicode replacement characters and won't work as-is — hand-edit before pasting"
);
} else if leading_dash {
let _ = writeln!(
stderr,
"linesmith: warning: --config path starts with `-` ({}); the emitted snippet would be parsed as a flag at re-invocation. Hand-edit the snippet's `command` field before pasting: prefix the path with `./`, use an absolute path, or insert `--` before it",
p.display()
);
} else if needs_quoting {
let _ = writeln!(
stderr,
"linesmith: warning: --config path contains characters that need shell quoting ({}); add quotes around the path in the snippet's `command` field before pasting",
p.display()
);
}
}
fn needs_shell_quoting(c: char) -> bool {
if matches!(
c,
' ' | '\t'
| '\''
| '"'
| '`'
| '$'
| '*'
| '?'
| '['
| ']'
| '&'
| ';'
| '|'
| '<'
| '>'
| '('
| ')'
| '{'
| '}'
| '#'
| '!'
| '='
| '^'
| '%'
) {
return true;
}
#[cfg(not(windows))]
{
matches!(c, '\\' | '~')
}
#[cfg(windows)]
{
false
}
}
fn compose_init_body(preset_body: &str, theme: &str) -> String {
if theme == "default" {
return preset_body.to_string();
}
let escaped = theme.replace('\\', "\\\\").replace('"', "\\\"");
let theme_line = format!("theme = \"{escaped}\"\n\n");
if preset_body.starts_with('[') {
return format!("{theme_line}{preset_body}");
}
if let Some(idx) = preset_body.find("\n[") {
let split = idx + 1;
let mut out = String::with_capacity(preset_body.len() + theme_line.len());
out.push_str(&preset_body[..split]);
out.push_str(&theme_line);
out.push_str(&preset_body[split..]);
out
} else {
format!("{theme_line}{preset_body}")
}
}
fn load_plugins(
cfg: Option<&config::Config>,
env: &CliEnv,
stderr: &mut dyn Write,
) -> (
Option<(PluginRegistry, std::sync::Arc<rhai::Engine>)>,
usize,
) {
let xdg_env = cli_env_to_xdg(env);
let Some((registry, engine)) = runtime::plugins::load_plugins(cfg, &xdg_env) else {
return (None, 0);
};
let errors = registry.load_errors();
let error_count = errors.len();
for err in errors {
let _ = writeln!(stderr, "linesmith: plugin: {err}");
}
(Some((registry, engine)), error_count)
}
fn cli_env_to_xdg(env: &CliEnv) -> crate::data_context::xdg::XdgEnv {
crate::data_context::xdg::XdgEnv::from_os_options(
None,
env.xdg_config_home.clone().map(std::ffi::OsString::from),
env.home.clone().map(std::ffi::OsString::from),
)
}
fn run_cli(
args: cli::CliArgs,
stdin: impl Read,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
env: &CliEnv,
) -> u8 {
let resolved = config::resolve_config_path(
args.config.clone(),
env.linesmith_config.as_deref(),
env.xdg_config_home.as_deref(),
env.home.as_deref(),
);
let (cfg, load_error, config_warnings) = load_config(resolved.as_ref(), stderr);
let registry = build_theme_registry(env, stderr);
if args.check_config {
return check_config(
resolved.as_ref(),
cfg.as_ref(),
load_error,
config_warnings,
®istry,
env,
stderr,
);
}
for msg in &config_warnings {
let _ = writeln!(stderr, "linesmith: {msg}");
}
let (plugins, _plugin_load_errors) = load_plugins(cfg.as_ref(), env, stderr);
let lines = build_lines(cfg.as_ref(), plugins, |msg| {
let _ = writeln!(stderr, "linesmith: {msg}");
});
let line_refs: Vec<&[Box<dyn crate::segments::Segment>]> =
lines.iter().map(Vec::as_slice).collect();
let raw_width = env.terminal_width.unwrap_or_else(detect_terminal_width);
let padding = layout_options(cfg.as_ref()).map_or(0, |l| l.claude_padding);
let width = raw_width.saturating_sub(padding);
let theme_ref = resolve_theme(cfg.as_ref(), ®istry, stderr);
let capability = resolve_color_capability(args.color_override, env, cfg.as_ref());
let hyperlinks = supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout);
let ctx = RunContext::new(theme_ref, capability, width, env.cwd.clone(), hyperlinks);
if let Err(err) = run_lines_with_context(stdin, stdout, stderr, &line_refs, &ctx) {
let _ = writeln!(stderr, "linesmith: {err}");
return 1;
}
0
}
fn build_theme_registry(env: &CliEnv, stderr: &mut dyn Write) -> theme::ThemeRegistry {
let xdg_env = cli_env_to_xdg(env);
let dir = runtime::themes::user_themes_dir(&xdg_env);
runtime::themes::build_theme_registry(dir.as_deref(), |msg| {
let _ = writeln!(stderr, "linesmith: {msg}");
})
}
fn layout_options(cfg: Option<&config::Config>) -> Option<&config::LayoutOptions> {
cfg.and_then(|c| c.layout_options.as_ref())
}
fn resolve_color_capability(
cli_override: Option<cli::ColorOverride>,
env: &CliEnv,
cfg: Option<&config::Config>,
) -> theme::Capability {
if let Some(cap) = env.color_capability {
return cap;
}
match cli_override {
Some(cli::ColorOverride::Never) => return theme::Capability::None,
Some(cli::ColorOverride::Always) => return force_color_detect(env),
None => {}
}
if env.no_color {
return theme::Capability::None;
}
if env.force_color {
return force_color_detect(env);
}
match layout_options(cfg).map(|l| l.color).unwrap_or_default() {
config::ColorPolicy::Never => theme::Capability::None,
config::ColorPolicy::Always => force_color_detect(env),
_ => theme::Capability::from_terminal(),
}
}
fn force_color_detect(env: &CliEnv) -> theme::Capability {
theme::Capability::force_from(
theme::Capability::from_terminal(),
theme::Capability::from_env_vars(env.colorterm.as_deref(), env.term.as_deref()),
)
}
fn resolve_theme<'a>(
cfg: Option<&config::Config>,
registry: &'a theme::ThemeRegistry,
stderr: &mut dyn Write,
) -> &'a theme::Theme {
let Some(name) = cfg
.and_then(|c| c.theme.as_deref())
.filter(|n| !n.is_empty())
else {
return registry
.lookup("default")
.expect("default theme is always in the registry");
};
match registry.lookup(name) {
Some(t) => t,
None => {
let _ = writeln!(stderr, "linesmith: unknown theme '{name}'; using 'default'");
registry
.lookup("default")
.expect("default theme is always in the registry")
}
}
}
fn load_config(
resolved: Option<&config::ConfigPath>,
stderr: &mut dyn Write,
) -> (
Option<config::Config>,
Option<config::ConfigError>,
Vec<String>,
) {
use runtime::config::ConfigLoadOutcome;
match runtime::config::load_config(resolved) {
ConfigLoadOutcome::Unresolved => (None, None, Vec::new()),
ConfigLoadOutcome::Loaded {
config, warnings, ..
} => (Some(*config), None, warnings),
ConfigLoadOutcome::NotFound { path, explicit } => {
if explicit {
let _ = writeln!(stderr, "linesmith: config not found at {}", path.display());
}
(None, None, Vec::new())
}
ConfigLoadOutcome::IoError {
source, warnings, ..
}
| ConfigLoadOutcome::ParseError {
source, warnings, ..
} => {
let _ = writeln!(stderr, "linesmith: {source}");
(None, Some(source), warnings)
}
_ => {
let _ = writeln!(
stderr,
"linesmith: unrecognized config load outcome (cli/core version skew); using defaults",
);
(None, None, Vec::new())
}
}
}
fn check_config(
resolved: Option<&config::ConfigPath>,
cfg: Option<&config::Config>,
load_error: Option<config::ConfigError>,
config_warnings: Vec<String>,
registry: &theme::ThemeRegistry,
env: &CliEnv,
stderr: &mut dyn Write,
) -> u8 {
let Some(cp) = resolved else {
let _ = writeln!(
stderr,
"linesmith: no config path (HOME and XDG_CONFIG_HOME both unset, no --config)"
);
return 1;
};
if load_error.is_some() {
let _ = writeln!(stderr, "linesmith: config invalid ({})", cp.path.display());
return 1;
}
let Some(cfg) = cfg else {
let _ = writeln!(
stderr,
"linesmith: no config at {}; using built-in defaults",
cp.path.display()
);
return 0;
};
let mut warn_count = 0_usize;
for msg in &config_warnings {
let _ = writeln!(stderr, "linesmith: {msg}");
warn_count += 1;
}
let (plugins, plugin_load_errors) = load_plugins(Some(cfg), env, stderr);
warn_count += plugin_load_errors;
let _ = build_lines(Some(cfg), plugins, |msg| {
let _ = writeln!(stderr, "linesmith: {msg}");
warn_count += 1;
});
if let Some(name) = cfg.theme.as_deref().filter(|n| !n.is_empty()) {
if registry.lookup(name).is_none() {
let _ = writeln!(stderr, "linesmith: unknown theme '{name}'; using 'default'");
warn_count += 1;
}
}
let _ = writeln!(stderr, "linesmith: config ok ({})", cp.path.display());
if warn_count > 0 {
let _ = writeln!(stderr, "linesmith: {warn_count} warning(s)");
}
0
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
use std::io::Cursor;
fn run_cli_main(args: &[&str], stdin: &[u8], env: &CliEnv) -> (u8, String, String) {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let args_owned: Vec<std::ffi::OsString> =
args.iter().map(std::ffi::OsString::from).collect();
let code = cli_main(
args_owned,
Cursor::new(stdin),
&mut stdout,
&mut stderr,
env,
);
(
code,
String::from_utf8(stdout).expect("stdout utf8"),
String::from_utf8(stderr).expect("stderr utf8"),
)
}
#[test]
fn help_flag_prints_help_to_stdout_and_exits_zero() {
let (code, stdout, stderr) = run_cli_main(&["--help"], b"", &CliEnv::for_tests());
assert_eq!(code, 0);
assert_eq!(stdout, cli::HELP);
assert!(stderr.is_empty());
}
#[test]
fn version_flag_prints_version_to_stdout_and_exits_zero() {
let (code, stdout, stderr) = run_cli_main(&["--version"], b"", &CliEnv::for_tests());
assert_eq!(code, 0);
assert_eq!(stdout, format!("linesmith {}\n", env!("CARGO_PKG_VERSION")));
assert!(stderr.is_empty());
}
#[test]
fn meta_flags_skip_terminal_width_detection() {
let (code, _stdout, stderr) = run_cli_main(&["--help"], b"", &CliEnv::default());
assert_eq!(code, 0);
assert!(
stderr.is_empty(),
"meta flag leaked width-detect output: {stderr}"
);
}
#[test]
fn unknown_flag_exits_two_and_prints_hint_to_stderr() {
let (code, stdout, stderr) = run_cli_main(&["--nope"], b"", &CliEnv::for_tests());
assert_eq!(code, 2);
assert!(stdout.is_empty());
assert!(stderr.contains("nope"));
assert!(stderr.contains("Try --help for usage."));
}
#[test]
fn empty_config_value_exits_two() {
let (code, _stdout, stderr) = run_cli_main(&["--config", ""], b"", &CliEnv::for_tests());
assert_eq!(code, 2);
assert!(stderr.contains("Try --help"));
}
#[test]
fn minimal_payload_round_trips_through_cli_main() {
let json = br#"{
"model": { "display_name": "Claude Test" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let (code, stdout, stderr) = run_cli_main(&[], json, &CliEnv::for_tests());
assert_eq!(code, 0);
assert_eq!(stdout, "Claude Test linesmith\n");
assert!(stderr.is_empty());
}
#[test]
fn malformed_json_renders_marker_and_routes_parse_error_to_injected_stderr() {
let (code, stdout, stderr) = run_cli_main(&[], b"{not json", &CliEnv::for_tests());
assert_eq!(code, 0);
assert_eq!(stdout, "?\n");
assert!(
stderr.contains("parse:"),
"expected parse diag, got: {stderr}"
);
}
#[test]
fn render_io_error_exits_one() {
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let json = br#"{"model":{"display_name":"Claude"},"workspace":{"project_dir":"/x"}}"#;
let mut stderr = Vec::new();
let env = CliEnv::for_tests();
let code = cli_main(
Vec::<std::ffi::OsString>::new(),
Cursor::new(json),
&mut FailingWriter,
&mut stderr,
&env,
);
assert_eq!(code, 1);
let stderr_str = String::from_utf8(stderr).expect("utf8");
assert!(stderr_str.contains("linesmith:"), "got: {stderr_str}");
}
#[test]
fn explicit_config_path_drives_render_not_just_check() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[line]\nsegments = [\"workspace\", \"model\"]\n").unwrap();
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let (code, stdout, _stderr) = run_cli_main(
&["--config", path.to_str().unwrap()],
json,
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert_eq!(stdout, "linesmith Claude\n");
}
#[test]
fn check_config_with_no_resolvable_path_exits_one() {
let (code, stdout, stderr) = run_cli_main(&["--check-config"], b"", &CliEnv::for_tests());
assert_eq!(code, 1);
assert!(stdout.is_empty());
assert!(stderr.contains("no config path"));
}
#[test]
fn check_config_with_valid_file_exits_zero_and_reports_ok() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[line]\nsegments = [\"model\", \"workspace\"]\n").unwrap();
let (code, stdout, stderr) = run_cli_main(
&["--check-config", "--config", path.to_str().unwrap()],
b"",
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert!(stdout.is_empty());
assert!(stderr.contains("config ok"));
assert!(stderr.contains(path.to_str().unwrap()));
}
#[test]
fn check_config_with_malformed_toml_exits_one_and_reports_invalid() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[line\nsegments =").unwrap();
let (code, stdout, stderr) = run_cli_main(
&["--check-config", "--config", path.to_str().unwrap()],
b"",
&CliEnv::for_tests(),
);
assert_eq!(code, 1);
assert!(stdout.is_empty());
assert!(stderr.contains("config invalid"));
}
#[test]
fn check_config_with_missing_explicit_path_warns_but_exits_zero() {
let dir = tempdir();
let missing = dir.path().join("nonexistent.toml");
let (code, _stdout, stderr) = run_cli_main(
&["--check-config", "--config", missing.to_str().unwrap()],
b"",
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert!(stderr.contains("config not found"));
assert!(stderr.contains("using built-in defaults"));
}
#[test]
fn check_config_surfaces_validation_warnings() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"[line]\nsegments = [\"model\", \"does_not_exist\"]\n",
)
.unwrap();
let (code, _stdout, stderr) = run_cli_main(
&["--check-config", "--config", path.to_str().unwrap()],
b"",
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert!(stderr.contains("does_not_exist"));
assert!(stderr.contains("1 warning(s)"));
}
#[test]
fn check_config_catches_unknown_top_level_key() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "thme = \"default\"\n").unwrap();
let (code, _stdout, stderr) = run_cli_main(
&["--check-config", "--config", path.to_str().unwrap()],
b"",
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert!(stderr.contains("thme"));
assert!(stderr.contains("1 warning(s)"));
}
#[test]
fn check_config_catches_unknown_segment_override_key() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[segments.model]\npriorty = 16\n").unwrap();
let (code, _stdout, stderr) = run_cli_main(
&["--check-config", "--config", path.to_str().unwrap()],
b"",
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert!(stderr.contains("priorty"));
assert!(stderr.contains("[segments.model]"));
assert!(stderr.contains("1 warning(s)"));
}
#[test]
fn check_config_counts_warnings_across_all_three_scopes() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
"thme = \"oops\"\n[layout_options]\nseparatr = \"x\"\n[segments.model]\npriorty = 1\n",
)
.unwrap();
let (code, _stdout, stderr) = run_cli_main(
&["--check-config", "--config", path.to_str().unwrap()],
b"",
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert!(stderr.contains("thme"));
assert!(stderr.contains("separatr"));
assert!(stderr.contains("priorty"));
assert!(stderr.contains("3 warning(s)"));
}
#[test]
fn unknown_key_warnings_emit_once_per_typo_on_render_path() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "thme = \"oops\"\n").unwrap();
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let (code, _stdout, stderr) = run_cli_main(
&["--config", path.to_str().unwrap()],
json,
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert_eq!(
stderr.matches("thme").count(),
1,
"unknown-key warning double-emitted: {stderr}"
);
}
#[test]
fn render_path_surfaces_unknown_key_warnings_on_stderr() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "thme = \"oops\"\n").unwrap();
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let (code, stdout, stderr) = run_cli_main(
&["--config", path.to_str().unwrap()],
json,
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert_eq!(stdout, "Claude linesmith\n");
assert!(stderr.contains("thme"));
}
#[test]
fn check_config_catches_unknown_theme_name() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "theme = \"defualt\"\n").unwrap();
let (code, _stdout, stderr) = run_cli_main(
&["--check-config", "--config", path.to_str().unwrap()],
b"",
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert!(stderr.contains("unknown theme 'defualt'"));
assert!(stderr.contains("1 warning(s)"));
}
#[test]
fn cli_env_routes_home_through_to_config_resolution() {
let dir = tempdir();
let cfg_dir = dir.path().join(".config/linesmith");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(
cfg_dir.join("config.toml"),
"[line]\nsegments = [\"model\"]\n",
)
.unwrap();
let env = CliEnv {
home: Some(dir.path().to_string_lossy().into_owned()),
..CliEnv::for_tests()
};
let (code, _stdout, stderr) = run_cli_main(&["--check-config"], b"", &env);
assert_eq!(code, 0);
assert!(stderr.contains("config ok"));
}
#[test]
fn cli_env_xdg_takes_precedence_over_home_in_resolution() {
let dir = tempdir();
let xdg_cfg = dir.path().join("xdg/linesmith");
std::fs::create_dir_all(&xdg_cfg).unwrap();
std::fs::write(xdg_cfg.join("config.toml"), "[line]\nsegments = []\n").unwrap();
let env = CliEnv {
xdg_config_home: Some(dir.path().join("xdg").to_string_lossy().into_owned()),
home: Some("/nowhere/that/exists".to_string()),
..CliEnv::for_tests()
};
let (code, _stdout, stderr) = run_cli_main(&["--check-config"], b"", &env);
assert_eq!(code, 0);
assert!(stderr.contains(dir.path().join("xdg").to_str().unwrap()));
}
#[test]
fn default_theme_under_palette16_wraps_segments_with_sgr() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let env = CliEnv {
color_capability: Some(theme::Capability::Palette16),
..CliEnv::for_tests()
};
let (code, stdout, _stderr) = run_cli_main(&[], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "\x1b[95mClaude\x1b[0m \x1b[96mlinesmith\x1b[0m\n");
}
#[test]
fn minimal_theme_under_palette16_emits_no_color() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "theme = \"minimal\"\n").unwrap();
let env = CliEnv {
color_capability: Some(theme::Capability::Palette16),
..CliEnv::for_tests()
};
let (code, stdout, _stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "Claude linesmith\n");
}
#[test]
fn multi_line_config_renders_one_writeln_per_line() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/proj" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
layout = "multi-line"
[line.1]
segments = ["model"]
[line.2]
segments = ["workspace"]
"#,
)
.unwrap();
let env = CliEnv::for_tests();
let (code, stdout, stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], json, &env);
assert_eq!(code, 0, "stderr:\n{stderr}");
assert_eq!(stdout, "Claude\nproj\n");
}
#[test]
fn multi_line_config_renders_lines_in_parsed_integer_order() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/proj" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
layout = "multi-line"
[line.10]
segments = ["workspace"]
[line.2]
segments = ["model"]
"#,
)
.unwrap();
let env = CliEnv::for_tests();
let (code, stdout, _stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "Claude\nproj\n");
}
#[test]
fn multi_line_with_no_numbered_tables_falls_back_to_single_line_render() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/x" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
layout = "multi-line"
[line]
segments = ["model"]
"#,
)
.unwrap();
let env = CliEnv::for_tests();
let (code, stdout, stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "Claude\n", "expected exactly one line");
assert!(
stderr.contains("no usable [line.N]"),
"fallback warning should reach stderr, got:\n{stderr}"
);
}
#[test]
fn check_config_surfaces_multi_line_validation_warnings() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
layout = "multi-line"
[line.1]
segments = ["model"]
[line.foo]
segments = ["bogus"]
"#,
)
.unwrap();
let env = CliEnv::for_tests();
let (_code, _stdout, stderr) = run_cli_main(
&["--config", path.to_str().unwrap(), "--check-config"],
b"",
&env,
);
assert!(
stderr.contains("[line.foo]") && stderr.contains("not a positive integer"),
"non-numeric-key warning should reach --check-config, got:\n{stderr}"
);
}
#[test]
fn multi_line_parse_failure_emits_one_question_marker_not_per_line() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
layout = "multi-line"
[line.1]
segments = ["model"]
[line.2]
segments = ["workspace"]
[line.3]
segments = ["context_window"]
"#,
)
.unwrap();
let env = CliEnv::for_tests();
let (code, stdout, stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], b"{not json", &env);
assert_eq!(code, 0, "parse failure renders the marker, exits 0");
assert_eq!(stdout, "?\n", "exactly one marker, not one per line");
assert!(
stderr.contains("parse:"),
"parse error should breadcrumb to stderr, got:\n{stderr}"
);
}
#[test]
fn multi_line_empty_segments_in_a_line_still_emits_trailing_newline() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/x" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
r#"
layout = "multi-line"
[line.1]
segments = ["model"]
[line.2]
segments = []
"#,
)
.unwrap();
let env = CliEnv::for_tests();
let (code, stdout, _stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], json, &env);
assert_eq!(code, 0);
assert_eq!(
stdout, "Claude\n\n",
"empty line slot must still emit `\\n`"
);
}
#[test]
fn power_user_preset_renders_two_lines_end_to_end() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/proj" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
presets::body("power-user").expect("preset registered"),
)
.unwrap();
let env = CliEnv::for_tests();
let (code, stdout, _stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], json, &env);
assert_eq!(code, 0);
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(
lines.len(),
2,
"power-user must render exactly two lines, got: {stdout:?}"
);
assert!(
lines[0].contains("Claude"),
"line 1 should carry model name, got: {:?}",
lines[0]
);
assert!(
lines[0].contains("proj"),
"line 1 should carry workspace name, got: {:?}",
lines[0]
);
}
#[test]
fn unknown_theme_falls_back_to_default_with_warning() {
let json = br#"{
"model": { "display_name": "C" },
"workspace": { "project_dir": "/x" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "theme = \"nonexistent\"\n").unwrap();
let env = CliEnv {
color_capability: Some(theme::Capability::None),
..CliEnv::for_tests()
};
let (code, stdout, stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "C x\n");
assert!(stderr.contains("unknown theme 'nonexistent'"));
assert!(stderr.contains("using 'default'"));
}
#[test]
fn catppuccin_mocha_renders_with_mocha_palette_under_truecolor() {
let json = br#"{
"model": { "display_name": "C" },
"workspace": { "project_dir": "/x" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "theme = \"catppuccin-mocha\"\n").unwrap();
let env = CliEnv {
color_capability: Some(theme::Capability::TrueColor),
..CliEnv::for_tests()
};
let (code, stdout, _stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], json, &env);
assert_eq!(code, 0);
assert_eq!(
stdout,
"\x1b[38;2;203;166;247mC\x1b[0m \x1b[38;2;148;226;213mx\x1b[0m\n"
);
}
#[test]
fn user_theme_from_disk_renders_with_configured_palette() {
let dir = tempdir();
let themes_dir = dir.path().join(".config/linesmith/themes");
std::fs::create_dir_all(&themes_dir).unwrap();
std::fs::write(
themes_dir.join("neon.toml"),
r##"
name = "neon"
[roles]
foreground = "#ffffff"
background = "#000000"
muted = "#888888"
primary = "#ff00ff"
accent = "#00ffff"
success = "#00ff00"
warning = "#ffff00"
error = "#ff0000"
info = "#0080ff"
"##,
)
.unwrap();
let cfg_dir = dir.path().join(".config/linesmith");
std::fs::write(cfg_dir.join("config.toml"), "theme = \"neon\"\n").unwrap();
let json = br#"{
"model": { "display_name": "C" },
"workspace": { "project_dir": "/x" }
}"#;
let env = CliEnv {
home: Some(dir.path().to_string_lossy().into_owned()),
color_capability: Some(theme::Capability::TrueColor),
..CliEnv::for_tests()
};
let (code, stdout, _stderr) = run_cli_main(&[], json, &env);
assert_eq!(code, 0);
assert_eq!(
stdout,
"\x1b[38;2;255;0;255mC\x1b[0m \x1b[38;2;0;128;255mx\x1b[0m\n"
);
}
#[test]
fn unknown_user_theme_name_falls_back_to_default_with_warning() {
let dir = tempdir();
std::fs::create_dir_all(dir.path().join(".config/linesmith/themes")).unwrap();
let cfg_dir = dir.path().join(".config/linesmith");
std::fs::create_dir_all(&cfg_dir).unwrap();
std::fs::write(cfg_dir.join("config.toml"), "theme = \"nonexistent\"\n").unwrap();
let env = CliEnv {
home: Some(dir.path().to_string_lossy().into_owned()),
..CliEnv::for_tests()
};
let json = br#"{
"model": { "display_name": "C" },
"workspace": { "project_dir": "/x" }
}"#;
let (code, stdout, stderr) = run_cli_main(&[], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "C x\n");
assert!(stderr.contains("unknown theme 'nonexistent'"));
}
#[test]
fn broken_user_theme_file_warns_but_does_not_abort_startup() {
let dir = tempdir();
let themes_dir = dir.path().join(".config/linesmith/themes");
std::fs::create_dir_all(&themes_dir).unwrap();
std::fs::write(themes_dir.join("broken.toml"), "not valid toml [[").unwrap();
let env = CliEnv {
home: Some(dir.path().to_string_lossy().into_owned()),
..CliEnv::for_tests()
};
let json = br#"{
"model": { "display_name": "C" },
"workspace": { "project_dir": "/x" }
}"#;
let (code, stdout, stderr) = run_cli_main(&[], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "C x\n");
assert!(stderr.contains("broken.toml"));
}
#[test]
fn check_config_accepts_user_theme_name() {
let dir = tempdir();
let themes_dir = dir.path().join(".config/linesmith/themes");
std::fs::create_dir_all(&themes_dir).unwrap();
std::fs::write(
themes_dir.join("myuser.toml"),
r##"
name = "myuser"
[roles]
foreground = "#ffffff"
background = "#000000"
muted = "#888888"
primary = "#ff00ff"
accent = "#00ffff"
success = "#00ff00"
warning = "#ffff00"
error = "#ff0000"
info = "#0080ff"
"##,
)
.unwrap();
let cfg_dir = dir.path().join(".config/linesmith");
std::fs::write(cfg_dir.join("config.toml"), "theme = \"myuser\"\n").unwrap();
let env = CliEnv {
home: Some(dir.path().to_string_lossy().into_owned()),
..CliEnv::for_tests()
};
let (code, _stdout, stderr) = run_cli_main(&["--check-config"], b"", &env);
assert_eq!(code, 0);
assert!(stderr.contains("config ok"));
assert!(!stderr.contains("unknown theme"));
}
#[test]
fn themes_list_prints_every_built_in_to_stdout() {
let (code, stdout, _stderr) = run_cli_main(&["themes", "list"], b"", &CliEnv::for_tests());
assert_eq!(code, 0);
for name in [
"default",
"minimal",
"catppuccin-latte",
"catppuccin-frappe",
"catppuccin-macchiato",
"catppuccin-mocha",
] {
assert!(
stdout.contains(&format!("{name}\tbuilt-in")),
"missing '{name}\\tbuilt-in' in:\n{stdout}"
);
}
}
#[test]
fn themes_list_includes_user_themes_with_source_path() {
let dir = tempdir();
let themes_dir = dir.path().join(".config").join("linesmith").join("themes");
std::fs::create_dir_all(&themes_dir).unwrap();
let user_theme = themes_dir.join("neon.toml");
std::fs::write(
&user_theme,
r##"
name = "neon"
[roles]
foreground = "#ffffff"
background = "#000000"
muted = "#888888"
primary = "#ff00ff"
accent = "#00ffff"
success = "#00ff00"
warning = "#ffff00"
error = "#ff0000"
info = "#0080ff"
"##,
)
.unwrap();
let env = CliEnv {
home: Some(dir.path().to_string_lossy().into_owned()),
..CliEnv::for_tests()
};
let (code, stdout, _stderr) = run_cli_main(&["themes", "list"], b"", &env);
assert_eq!(code, 0);
assert!(
stdout.contains("neon\t"),
"missing user theme line:\n{stdout}"
);
assert!(
stdout.contains(user_theme.to_str().unwrap()),
"missing source path in:\n{stdout}"
);
}
#[test]
fn user_themes_dir_prefers_xdg_over_home() {
let dir = tempdir();
let xdg_themes = dir.path().join("xdg/linesmith/themes");
let home_themes = dir.path().join("home/.config/linesmith/themes");
std::fs::create_dir_all(&xdg_themes).unwrap();
std::fs::create_dir_all(&home_themes).unwrap();
std::fs::write(
xdg_themes.join("xdg_theme.toml"),
r##"
name = "xdgonly"
[roles]
foreground = "#aaaaaa"
background = "#000000"
muted = "#888888"
primary = "#ff00ff"
accent = "#00ffff"
success = "#00ff00"
warning = "#ffff00"
error = "#ff0000"
info = "#0080ff"
"##,
)
.unwrap();
std::fs::write(
home_themes.join("home_theme.toml"),
r##"
name = "homeonly"
[roles]
foreground = "#bbbbbb"
background = "#000000"
muted = "#888888"
primary = "#ff00ff"
accent = "#00ffff"
success = "#00ff00"
warning = "#ffff00"
error = "#ff0000"
info = "#0080ff"
"##,
)
.unwrap();
let env = CliEnv {
xdg_config_home: Some(dir.path().join("xdg").to_string_lossy().into_owned()),
home: Some(dir.path().join("home").to_string_lossy().into_owned()),
..CliEnv::for_tests()
};
let (code, stdout, _stderr) = run_cli_main(&["themes", "list"], b"", &env);
assert_eq!(code, 0);
assert!(
stdout.contains("xdgonly"),
"XDG theme missing from list:\n{stdout}",
);
assert!(
!stdout.contains("homeonly"),
"HOME theme leaked in when XDG was set:\n{stdout}",
);
}
#[test]
fn unknown_subcommand_exits_two() {
let (code, _stdout, stderr) =
run_cli_main(&["bogus", "command"], b"", &CliEnv::for_tests());
assert_eq!(code, 2);
assert!(stderr.contains("Try --help"));
}
#[test]
fn themes_without_subcommand_exits_two() {
let (code, _stdout, stderr) = run_cli_main(&["themes"], b"", &CliEnv::for_tests());
assert_eq!(code, 2);
assert!(stderr.contains("Try --help"));
}
#[test]
fn no_color_capability_strips_theme_under_default() {
let json = br#"{
"model": { "display_name": "C" },
"workspace": { "project_dir": "/x" }
}"#;
let env = CliEnv {
color_capability: Some(theme::Capability::None),
..CliEnv::for_tests()
};
let (code, stdout, _stderr) = run_cli_main(&[], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "C x\n");
}
fn policy_env() -> CliEnv {
CliEnv {
color_capability: None,
..CliEnv::for_tests()
}
}
#[test]
fn color_policy_cli_never_wins_over_force_env() {
let env = CliEnv {
force_color: true,
..policy_env()
};
assert_eq!(
resolve_color_capability(Some(cli::ColorOverride::Never), &env, None),
theme::Capability::None,
);
}
#[test]
fn color_policy_cli_always_wins_over_no_color_env() {
let env = CliEnv {
no_color: true,
..policy_env()
};
let got = resolve_color_capability(Some(cli::ColorOverride::Always), &env, None);
assert!(got >= theme::Capability::Palette16, "got {got:?}");
}
#[test]
fn color_policy_no_color_env_wins_over_force_env() {
let env = CliEnv {
no_color: true,
force_color: true,
..policy_env()
};
assert_eq!(
resolve_color_capability(None, &env, None),
theme::Capability::None,
);
}
fn layout_options_with_color(color: config::ColorPolicy) -> config::LayoutOptions {
let mut opts = config::LayoutOptions::default();
opts.color = color;
opts
}
#[test]
fn color_policy_no_color_env_wins_over_config_always() {
let cfg = config::Config {
layout_options: Some(layout_options_with_color(config::ColorPolicy::Always)),
..config::Config::default()
};
let env = CliEnv {
no_color: true,
..policy_env()
};
assert_eq!(
resolve_color_capability(None, &env, Some(&cfg)),
theme::Capability::None,
);
}
#[test]
fn color_policy_config_never_strips_color() {
let cfg = config::Config {
layout_options: Some(layout_options_with_color(config::ColorPolicy::Never)),
..config::Config::default()
};
assert_eq!(
resolve_color_capability(None, &policy_env(), Some(&cfg)),
theme::Capability::None,
);
}
#[test]
fn color_policy_config_always_forces_color() {
let cfg = config::Config {
layout_options: Some(layout_options_with_color(config::ColorPolicy::Always)),
..config::Config::default()
};
let got = resolve_color_capability(None, &policy_env(), Some(&cfg));
assert!(got >= theme::Capability::Palette16, "got {got:?}");
}
#[test]
fn color_policy_config_auto_falls_through_to_terminal_detection() {
let cfg = config::Config {
layout_options: Some(layout_options_with_color(config::ColorPolicy::Auto)),
..config::Config::default()
};
let got = resolve_color_capability(None, &policy_env(), Some(&cfg));
assert_eq!(got, theme::Capability::from_terminal());
}
#[test]
fn force_color_detect_never_returns_none() {
assert_ne!(
force_color_detect(&CliEnv::default()),
theme::Capability::None
);
}
#[test]
fn force_color_detect_reads_colorterm_from_cli_env_not_ambient_process() {
let env = CliEnv {
colorterm: Some("truecolor".to_string()),
term: Some("xterm-ghostty".to_string()),
..CliEnv::default()
};
assert_eq!(force_color_detect(&env), theme::Capability::TrueColor);
}
#[test]
fn color_policy_force_color_env_zero_is_treated_as_absent() {
std::env::set_var("LINESMITH_FORCE_COLOR_TEST_ZERO", "0");
assert!(!force_color_env("LINESMITH_FORCE_COLOR_TEST_ZERO"));
std::env::set_var("LINESMITH_FORCE_COLOR_TEST_ONE", "1");
assert!(force_color_env("LINESMITH_FORCE_COLOR_TEST_ONE"));
std::env::set_var("LINESMITH_FORCE_COLOR_TEST_EMPTY", "");
assert!(!force_color_env("LINESMITH_FORCE_COLOR_TEST_EMPTY"));
std::env::remove_var("LINESMITH_FORCE_COLOR_TEST_ZERO");
std::env::remove_var("LINESMITH_FORCE_COLOR_TEST_ONE");
std::env::remove_var("LINESMITH_FORCE_COLOR_TEST_EMPTY");
}
#[test]
fn color_policy_test_capability_override_short_circuits_everything() {
let env = CliEnv {
no_color: true,
force_color: true,
color_capability: Some(theme::Capability::Palette256),
..policy_env()
};
assert_eq!(
resolve_color_capability(Some(cli::ColorOverride::Never), &env, None),
theme::Capability::Palette256,
);
}
#[test]
fn claude_padding_shrinks_render_budget_and_drops_segment() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[layout_options]\nclaude_padding = 10\n").unwrap();
let env = CliEnv {
terminal_width: Some(20),
..CliEnv::for_tests()
};
let (code, stdout, _stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "linesmith\n");
}
#[test]
fn claude_padding_exceeds_width_clamps_to_zero_and_drops_everything() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[layout_options]\nclaude_padding = 500\n").unwrap();
let env = CliEnv {
terminal_width: Some(80),
..CliEnv::for_tests()
};
let (code, stdout, _stderr) =
run_cli_main(&["--config", path.to_str().unwrap()], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "\n");
}
#[test]
fn claude_padding_zero_matches_no_padding() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[layout_options]\nclaude_padding = 0\n").unwrap();
let (code, stdout, _stderr) = run_cli_main(
&["--config", path.to_str().unwrap()],
json,
&CliEnv::for_tests(),
);
assert_eq!(code, 0);
assert_eq!(stdout, "Claude linesmith\n");
}
#[test]
fn no_color_flag_outranks_force_color_env_end_to_end() {
let json = br#"{
"model": { "display_name": "Claude" },
"workspace": { "project_dir": "/home/dev/linesmith" }
}"#;
let env = CliEnv {
force_color: true,
color_capability: None,
..CliEnv::for_tests()
};
let (code, stdout, _stderr) = run_cli_main(&["--no-color"], json, &env);
assert_eq!(code, 0);
assert_eq!(stdout, "Claude linesmith\n");
assert!(!stdout.contains('\x1b'));
}
struct TempDir(std::path::PathBuf);
impl TempDir {
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn env_with_home(dir: &Path) -> CliEnv {
CliEnv {
home: Some(dir.to_string_lossy().into_owned()),
..CliEnv::for_tests()
}
}
#[test]
fn presets_list_prints_every_preset_name() {
let (code, stdout, _stderr) = run_cli_main(&["presets", "list"], b"", &CliEnv::for_tests());
assert_eq!(code, 0);
for name in crate::presets::names() {
assert!(stdout.contains(name), "missing '{name}' in:\n{stdout}");
}
}
#[test]
fn presets_apply_writes_parsed_config_to_resolved_path() {
use std::str::FromStr;
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, stdout, stderr) = run_cli_main(&["presets", "apply", "minimal"], b"", &env);
assert_eq!(code, 0, "stderr:\n{stderr}");
let expected = dir.path().join(".config/linesmith/config.toml");
assert!(expected.exists(), "config.toml not written");
let written = std::fs::read_to_string(&expected).unwrap();
let cfg = config::Config::from_str(&written).expect("round-trips");
assert_eq!(
cfg.line.expect("has [line]").segments,
vec!["model".to_string(), "context_window".to_string()]
);
assert!(stdout.contains("wrote preset 'minimal'"));
}
#[test]
fn presets_apply_unknown_name_errors_and_lists_valid() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_cli_main(&["presets", "apply", "bogus"], b"", &env);
assert_eq!(code, 1);
assert!(stderr.contains("unknown preset 'bogus'"));
assert!(stderr.contains("developer"));
}
#[test]
fn presets_apply_prompts_on_existing_config_and_accepts_y() {
let dir = tempdir();
let cfg = dir.path().join(".config/linesmith/config.toml");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# prior content\n").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_cli_main(&["presets", "apply", "minimal"], b"y\n", &env);
assert_eq!(code, 0);
let backup = dir.path().join(".config/linesmith/config.toml.bak");
assert!(backup.exists(), "expected .bak");
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
"# prior content\n"
);
assert!(std::fs::read_to_string(&cfg)
.unwrap()
.contains("preset: minimal"));
assert!(stderr.contains("overwrite"));
assert!(stderr.contains("backed up previous config to"));
}
#[test]
fn presets_apply_prompt_rejects_on_n_and_leaves_config_untouched() {
let dir = tempdir();
let cfg = dir.path().join(".config/linesmith/config.toml");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# prior content\n").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_cli_main(&["presets", "apply", "minimal"], b"n\n", &env);
assert_eq!(code, 1);
assert!(stderr.contains("aborted"));
assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "# prior content\n");
}
#[test]
fn presets_apply_force_skips_prompt_and_backs_up() {
let dir = tempdir();
let cfg = dir.path().join(".config/linesmith/config.toml");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# prior content\n").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, _stderr) =
run_cli_main(&["presets", "apply", "developer", "--force"], b"", &env);
assert_eq!(code, 0);
let backup = dir.path().join(".config/linesmith/config.toml.bak");
assert!(backup.exists());
assert!(std::fs::read_to_string(&cfg)
.unwrap()
.contains("preset: developer"));
}
#[test]
fn presets_apply_eof_without_force_aborts() {
let dir = tempdir();
let cfg = dir.path().join(".config/linesmith/config.toml");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# prior\n").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_cli_main(&["presets", "apply", "minimal"], b"", &env);
assert_eq!(code, 1);
assert!(stderr.contains("aborted"));
}
#[test]
fn presets_apply_refuses_to_clobber_existing_backup_without_force() {
let dir = tempdir();
let cfg = dir.path().join(".config/linesmith/config.toml");
let bak = dir.path().join(".config/linesmith/config.toml.bak");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# current\n").unwrap();
std::fs::write(&bak, "# older generation\n").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_cli_main(&["presets", "apply", "minimal"], b"y\n", &env);
assert_eq!(code, 1);
assert!(stderr.contains("already exists"));
assert!(stderr.contains("--force"));
assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "# current\n");
assert_eq!(
std::fs::read_to_string(&bak).unwrap(),
"# older generation\n"
);
}
#[test]
fn presets_apply_force_replaces_existing_backup() {
let dir = tempdir();
let cfg = dir.path().join(".config/linesmith/config.toml");
let bak = dir.path().join(".config/linesmith/config.toml.bak");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# current\n").unwrap();
std::fs::write(&bak, "# older generation\n").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, _stderr) =
run_cli_main(&["presets", "apply", "minimal", "--force"], b"", &env);
assert_eq!(code, 0);
assert_eq!(std::fs::read_to_string(&bak).unwrap(), "# current\n");
assert!(std::fs::read_to_string(&cfg)
.unwrap()
.contains("preset: minimal"));
}
#[test]
fn presets_apply_honors_explicit_config_flag_over_xdg_path() {
let dir = tempdir();
let custom = dir.path().join("custom-preset.toml");
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_cli_main(
&[
"--config",
custom.to_str().unwrap(),
"presets",
"apply",
"minimal",
],
b"",
&env,
);
assert_eq!(code, 0, "stderr:\n{stderr}");
assert!(custom.exists(), "expected preset written to --config path");
assert!(!dir.path().join(".config/linesmith/config.toml").exists());
}
#[test]
fn presets_apply_creates_missing_parent_dirs() {
let dir = tempdir();
assert!(!dir.path().join(".config").exists());
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_cli_main(&["presets", "apply", "minimal"], b"", &env);
assert_eq!(code, 0, "stderr:\n{stderr}");
assert!(dir.path().join(".config/linesmith").is_dir());
assert!(dir.path().join(".config/linesmith/config.toml").exists());
}
#[test]
fn presets_apply_empty_name_fails_with_unknown_preset() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_cli_main(&["presets", "apply", ""], b"", &env);
assert_eq!(code, 1);
assert!(stderr.contains("unknown preset ''"));
}
#[test]
fn presets_apply_write_failure_reports_stderr_and_exits_one() {
let dir = tempdir();
let not_a_dir = dir.path().join(".config/linesmith");
std::fs::create_dir_all(not_a_dir.parent().unwrap()).unwrap();
std::fs::write(¬_a_dir, "I am a file, not a directory").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_cli_main(&["presets", "apply", "minimal"], b"", &env);
assert_eq!(code, 1);
assert!(
stderr.contains("could not create"),
"expected 'could not create' diagnostic, got: {stderr}"
);
}
#[test]
fn presets_list_ignores_force_flag_by_rejecting_it() {
let (code, _stdout, stderr) =
run_cli_main(&["--force", "presets", "list"], b"", &CliEnv::for_tests());
assert_eq!(code, 2, "CLI parse error should exit 2");
assert!(stderr.contains("--force"));
}
#[test]
fn parse_confirmation_accepts_y_yes_case_insensitive_and_trims_whitespace() {
for ok in [
"y", "Y", "yes", "YES", "Yes", " y \n", "yes\r\n", " YES \t",
] {
assert!(super::parse_confirmation(ok), "expected yes for {ok:?}");
}
for no in ["", "\n", " ", "yeah", "ye", "no", "n", "maybe", "yess"] {
assert!(!super::parse_confirmation(no), "expected no for {no:?}");
}
}
fn run_init(
choices: InitChoices,
config_override: Option<PathBuf>,
stdin: &[u8],
env: &CliEnv,
) -> (u8, String, String) {
run_init_with_doctor(
choices,
true,
config_override,
stdin,
env,
)
}
fn run_init_with_doctor(
choices: InitChoices,
no_doctor: bool,
config_override: Option<PathBuf>,
stdin: &[u8],
env: &CliEnv,
) -> (u8, String, String) {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = super::init_with_choices(
&choices,
no_doctor,
config_override,
io::Cursor::new(stdin),
&mut stdout,
&mut stderr,
env,
);
(
code,
String::from_utf8(stdout).expect("stdout utf8"),
String::from_utf8(stderr).expect("stderr utf8"),
)
}
fn init_choices(preset: &str, theme: &str) -> InitChoices {
InitChoices {
preset: preset.to_string(),
theme: theme.to_string(),
}
}
#[test]
fn init_creates_valid_config_round_trips_parse() {
use std::str::FromStr;
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, stdout, stderr) =
run_init(init_choices("minimal", "catppuccin-mocha"), None, b"", &env);
assert_eq!(code, 0, "stderr:\n{stderr}");
let path = dir.path().join(".config/linesmith/config.toml");
let written = std::fs::read_to_string(&path).expect("file exists");
let cfg = config::Config::from_str(&written).expect("round-trips");
assert_eq!(cfg.theme.as_deref(), Some("catppuccin-mocha"));
assert_eq!(
cfg.line.expect("has [line]").segments,
vec!["model".to_string(), "context_window".to_string()]
);
assert!(stdout.contains("wrote config.toml to"));
}
#[test]
fn init_no_doctor_writes_config_without_running_doctor() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, stdout, stderr) = run_init_with_doctor(
init_choices("minimal", "catppuccin-mocha"),
true,
None,
b"",
&env,
);
assert_eq!(code, 0, "stderr:\n{stderr}");
assert!(
stdout.contains("wrote config.toml to"),
"init still writes the config:\n{stdout}",
);
assert!(
!stdout.contains("Running doctor"),
"doctor must NOT run with --no-doctor; got stdout:\n{stdout}",
);
assert!(
!stdout.contains("linesmith doctor (v"),
"doctor report must NOT appear with --no-doctor; got stdout:\n{stdout}",
);
}
#[test]
fn init_runs_doctor_inline_after_writing_config() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (_code, stdout, _stderr) = run_init_with_doctor(
init_choices("minimal", "catppuccin-mocha"),
false,
None,
b"",
&env,
);
assert!(
stdout.contains("wrote config.toml to"),
"init must write the config before invoking doctor:\n{stdout}",
);
assert!(
stdout.contains("Running doctor to verify your setup"),
"init must announce the doctor run:\n{stdout}",
);
assert!(
stdout.contains("linesmith doctor (v"),
"doctor report header must appear inline:\n{stdout}",
);
assert!(
stdout.contains("Config file:") && stdout.contains("config.toml"),
"doctor should report on the freshly-written config:\n{stdout}",
);
}
#[test]
fn init_propagates_doctor_exit_code() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, _stdout, _stderr) = run_init_with_doctor(
init_choices("minimal", "catppuccin-mocha"),
false,
None,
b"",
&env,
);
assert!(
code == 0 || code == 1,
"init should propagate doctor's exit code (0 or 1); got {code}",
);
}
#[test]
fn init_writes_to_resolved_xdg_path() {
let xdg_dir = tempdir();
let home_dir = tempdir();
let env = CliEnv {
xdg_config_home: Some(xdg_dir.path().to_string_lossy().into_owned()),
home: Some(home_dir.path().to_string_lossy().into_owned()),
..CliEnv::for_tests()
};
let (code, _stdout, stderr) = run_init(init_choices("minimal", "default"), None, b"", &env);
assert_eq!(code, 0, "stderr:\n{stderr}");
assert!(
xdg_dir.path().join("linesmith/config.toml").exists(),
"expected config at XDG path"
);
assert!(
!home_dir
.path()
.join(".config/linesmith/config.toml")
.exists(),
"HOME path should not be touched when XDG resolves"
);
}
fn parse_snippet(stdout: &str) -> serde_json::Value {
let lines: Vec<&str> = stdout.lines().collect();
let start = lines
.iter()
.position(|l| l.contains("\"statusLine\""))
.expect("snippet header present");
let end = lines[start..]
.iter()
.position(|l| l.trim_end() == " }")
.map(|i| start + i)
.expect("snippet closing brace present");
let body: String = lines[start..=end].join("\n");
let wrapped = format!("{{\n{body}\n}}");
serde_json::from_str(&wrapped)
.unwrap_or_else(|e| panic!("snippet not valid JSON ({e}):\n{wrapped}"))
}
#[test]
fn init_emits_claude_code_settings_snippet() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, stdout, _stderr) = run_init(init_choices("minimal", "default"), None, b"", &env);
assert_eq!(code, 0);
let parsed = parse_snippet(&stdout);
let status_line = &parsed["statusLine"];
assert_eq!(status_line["type"], "command");
assert_eq!(status_line["command"], "linesmith");
assert_eq!(status_line["padding"], 0);
assert!(
stdout.contains("settings.json"),
"snippet should name the destination file"
);
assert!(
stdout.contains("merge into the existing top-level object"),
"merge hint missing — without it, users pasting into an empty file get invalid JSON",
);
}
#[test]
fn init_snippet_preserves_explicit_config_flag() {
let dir = tempdir();
let custom = dir.path().join("custom-init.toml");
let env = env_with_home(dir.path());
let (code, stdout, _stderr) = run_init(
init_choices("minimal", "default"),
Some(custom.clone()),
b"",
&env,
);
assert_eq!(code, 0);
let parsed = parse_snippet(&stdout);
let cmd = parsed["statusLine"]["command"]
.as_str()
.expect("command is a string");
assert!(
cmd.starts_with("linesmith --config "),
"expected `--config` in command, got: {cmd}"
);
assert!(
cmd.contains(custom.to_string_lossy().as_ref()),
"command should reference the actual --config path: {cmd}"
);
}
#[test]
fn init_snippet_preserves_env_resolved_config_path() {
let dir = tempdir();
let custom = dir.path().join("env-init.toml");
let env = CliEnv {
linesmith_config: Some(custom.to_string_lossy().into_owned()),
..env_with_home(dir.path())
};
let (code, stdout, _stderr) = run_init(init_choices("minimal", "default"), None, b"", &env);
assert_eq!(code, 0);
let parsed = parse_snippet(&stdout);
let cmd = parsed["statusLine"]["command"]
.as_str()
.expect("command is a string");
assert!(
cmd.starts_with("linesmith --config "),
"env-resolved path should still produce --config snippet, got: {cmd}"
);
assert!(
cmd.contains(custom.to_string_lossy().as_ref()),
"command should reference the env-resolved path: {cmd}"
);
}
#[test]
fn init_snippet_uses_bare_command_for_implicit_xdg_path() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, stdout, _stderr) = run_init(init_choices("minimal", "default"), None, b"", &env);
assert_eq!(code, 0);
let parsed = parse_snippet(&stdout);
assert_eq!(parsed["statusLine"]["command"], "linesmith");
}
#[test]
fn init_warns_about_env_resolved_path_with_spaces() {
let dir = tempdir();
let custom = dir.path().join("env path with spaces.toml");
let env = CliEnv {
linesmith_config: Some(custom.to_string_lossy().into_owned()),
..env_with_home(dir.path())
};
let (code, _stdout, stderr) = run_init(init_choices("minimal", "default"), None, b"", &env);
assert_eq!(code, 0);
assert!(
stderr.contains("characters that need shell quoting"),
"missing shell-quoting warning for env-resolved path:\n{stderr}"
);
}
#[cfg(not(windows))]
#[test]
fn init_snippet_escapes_quotes_and_backslashes_in_config_path() {
let dir = tempdir();
let env = env_with_home(dir.path());
let weird = dir.path().join("weird\"path\\ok.toml");
let (code, stdout, _stderr) = run_init(
init_choices("minimal", "default"),
Some(weird.clone()),
b"",
&env,
);
assert_eq!(code, 0);
let parsed = parse_snippet(&stdout);
let cmd = parsed["statusLine"]["command"]
.as_str()
.expect("command is a string");
assert!(cmd.contains(weird.to_string_lossy().as_ref()));
}
#[test]
fn init_succeeds_when_both_config_and_backup_already_exist() {
let dir = tempdir();
let cfg = dir.path().join(".config/linesmith/config.toml");
let bak = dir.path().join(".config/linesmith/config.toml.bak");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# current\n").unwrap();
std::fs::write(&bak, "# older generation\n").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) =
run_init(init_choices("minimal", "default"), None, b"y\n", &env);
assert_eq!(
code, 0,
"init must not deadlock when .bak already exists\nstderr:\n{stderr}"
);
assert_eq!(std::fs::read_to_string(&bak).unwrap(), "# current\n");
assert_eq!(
std::fs::read_to_string(&cfg).unwrap(),
config::with_schema_directive(presets::body("minimal").unwrap())
);
}
#[test]
fn init_user_says_no_to_overwrite_does_not_clobber_backup() {
let dir = tempdir();
let cfg = dir.path().join(".config/linesmith/config.toml");
let bak = dir.path().join(".config/linesmith/config.toml.bak");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# current\n").unwrap();
std::fs::write(&bak, "# older generation\n").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, _stderr) =
run_init(init_choices("minimal", "default"), None, b"n\n", &env);
assert_eq!(code, 1);
assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "# current\n");
assert_eq!(
std::fs::read_to_string(&bak).unwrap(),
"# older generation\n"
);
}
#[test]
fn init_writes_schema_directive_at_top_of_config() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_init(init_choices("minimal", "default"), None, b"", &env);
assert_eq!(code, 0, "stderr:\n{stderr}");
let written = std::fs::read_to_string(dir.path().join(".config/linesmith/config.toml"))
.expect("file exists");
assert!(
written.starts_with("#:schema https://"),
"expected schema directive at top, got:\n{written}"
);
assert!(
written.contains("config.schema.json"),
"schema URL must point at config.schema.json"
);
}
#[test]
fn presets_apply_writes_schema_directive_at_top_of_config() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, _stdout, _stderr) = run_cli_main(&["presets", "apply", "minimal"], b"", &env);
assert_eq!(code, 0);
let written = std::fs::read_to_string(dir.path().join(".config/linesmith/config.toml"))
.expect("file exists");
assert!(
written.starts_with("#:schema https://"),
"expected schema directive at top, got:\n{written}"
);
}
#[test]
fn init_default_theme_omits_theme_field_to_match_presets_apply() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_init(init_choices("minimal", "default"), None, b"", &env);
assert_eq!(code, 0, "stderr:\n{stderr}");
let written = std::fs::read_to_string(dir.path().join(".config/linesmith/config.toml"))
.expect("file exists");
assert_eq!(
written,
config::with_schema_directive(presets::body("minimal").expect("preset registered"))
);
}
#[test]
fn init_overwrite_prompts_and_backs_up_existing_config() {
let dir = tempdir();
let cfg = dir.path().join(".config/linesmith/config.toml");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# prior content\n").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) =
run_init(init_choices("minimal", "default"), None, b"y\n", &env);
assert_eq!(code, 0, "stderr:\n{stderr}");
let backup = dir.path().join(".config/linesmith/config.toml.bak");
assert!(backup.exists(), "expected .bak");
assert_eq!(
std::fs::read_to_string(&backup).unwrap(),
"# prior content\n"
);
assert!(stderr.contains("backed up previous config to"));
}
#[test]
fn init_eof_on_overwrite_aborts_without_clobbering() {
let dir = tempdir();
let cfg = dir.path().join(".config/linesmith/config.toml");
std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
std::fs::write(&cfg, "# prior\n").unwrap();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_init(init_choices("minimal", "default"), None, b"", &env);
assert_eq!(code, 1);
assert!(stderr.contains("aborted"));
assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "# prior\n");
}
#[test]
fn init_honors_explicit_config_flag_over_xdg_path() {
let dir = tempdir();
let custom = dir.path().join("custom-init.toml");
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_init(
init_choices("minimal", "default"),
Some(custom.clone()),
b"",
&env,
);
assert_eq!(code, 0, "stderr:\n{stderr}");
assert!(custom.exists(), "expected init at --config path");
assert!(!dir.path().join(".config/linesmith/config.toml").exists());
}
#[test]
fn init_unknown_preset_in_choices_errors() {
let dir = tempdir();
let env = env_with_home(dir.path());
let (code, _stdout, stderr) =
run_init(init_choices("not-a-preset", "default"), None, b"", &env);
assert_eq!(code, 1);
assert!(stderr.contains("unknown preset 'not-a-preset'"));
}
#[test]
fn init_without_resolvable_path_errors() {
let env = CliEnv {
home: None,
..CliEnv::for_tests()
};
let (code, _stdout, stderr) = run_init(init_choices("minimal", "default"), None, b"", &env);
assert_eq!(code, 1);
assert!(stderr.contains("cannot resolve"));
}
#[test]
fn compose_init_body_default_theme_passes_body_through() {
let body = "# hdr\n\n[line]\nsegments = [\"model\"]\n";
assert_eq!(super::compose_init_body(body, "default"), body);
}
#[test]
fn compose_init_body_inserts_theme_before_first_table() {
let body = "# hdr\n# more\n\n[line]\nsegments = [\"model\"]\n";
let out = super::compose_init_body(body, "catppuccin-mocha");
assert_eq!(
out,
"# hdr\n# more\n\ntheme = \"catppuccin-mocha\"\n\n[line]\nsegments = [\"model\"]\n"
);
}
#[test]
fn compose_init_body_escapes_quotes_and_backslashes_in_theme_name() {
let body = "[line]\nsegments = []\n";
let out = super::compose_init_body(body, "weird\"name\\foo");
assert!(
out.starts_with("theme = \"weird\\\"name\\\\foo\"\n\n"),
"unexpected escape: {out:?}"
);
use std::str::FromStr;
let cfg = config::Config::from_str(&out).expect("escaped TOML parses");
assert_eq!(cfg.theme.as_deref(), Some("weird\"name\\foo"));
}
#[test]
fn compose_init_body_handles_table_header_at_byte_zero() {
use std::str::FromStr;
let body = "[line]\nsegments = [\"model\"]\n";
let out = super::compose_init_body(body, "catppuccin-mocha");
assert_eq!(
out,
"theme = \"catppuccin-mocha\"\n\n[line]\nsegments = [\"model\"]\n"
);
let cfg = config::Config::from_str(&out).expect("parses");
assert_eq!(cfg.theme.as_deref(), Some("catppuccin-mocha"));
}
#[test]
fn compose_init_body_falls_back_when_preset_has_no_tables() {
use std::str::FromStr;
let body = "# only a comment\n";
let out = super::compose_init_body(body, "catppuccin-mocha");
assert!(
out.starts_with("theme = \"catppuccin-mocha\"\n\n"),
"unexpected output: {out:?}"
);
assert!(out.ends_with("# only a comment\n"));
let cfg = config::Config::from_str(&out).expect("parses");
assert_eq!(cfg.theme.as_deref(), Some("catppuccin-mocha"));
}
#[test]
fn init_warns_when_config_path_contains_spaces() {
let dir = tempdir();
let custom = dir.path().join("path with spaces.toml");
let env = env_with_home(dir.path());
let (code, _stdout, stderr) = run_init(
init_choices("minimal", "default"),
Some(custom.clone()),
b"",
&env,
);
assert_eq!(code, 0);
assert!(
stderr.contains("characters that need shell quoting"),
"missing shell-quoting warning, stderr:\n{stderr}"
);
assert!(
stderr.contains("path with spaces.toml"),
"warning should name the path"
);
}
#[test]
fn init_does_not_warn_for_plain_ascii_config_path() {
let dir = tempdir();
let custom = dir.path().join("plain.toml");
let env = env_with_home(dir.path());
let (_code, _stdout, stderr) =
run_init(init_choices("minimal", "default"), Some(custom), b"", &env);
assert!(
!stderr.contains("characters that need shell quoting"),
"false-positive warning, stderr:\n{stderr}"
);
assert!(
!stderr.contains("non-UTF-8"),
"false-positive warning, stderr:\n{stderr}"
);
}
#[test]
fn init_warns_when_config_path_contains_shell_metacharacters() {
let dir = tempdir();
let custom = dir.path().join("weird$path.toml");
let env = env_with_home(dir.path());
let (code, _stdout, stderr) =
run_init(init_choices("minimal", "default"), Some(custom), b"", &env);
assert_eq!(code, 0);
assert!(
stderr.contains("characters that need shell quoting"),
"missing warning for `$`, stderr:\n{stderr}"
);
}
#[test]
fn needs_shell_quoting_flags_metacharacters_and_whitespace() {
for c in [' ', '\t', '\'', '"', '$', '&', ';', '|', '*', '?', '^', '%'] {
assert!(
super::needs_shell_quoting(c),
"expected {c:?} to need quoting"
);
}
for c in ['a', 'Z', '0', '_', '-', '.', '/'] {
assert!(
!super::needs_shell_quoting(c),
"expected {c:?} to NOT need quoting"
);
}
for c in ['\\', '~'] {
#[cfg(not(windows))]
assert!(
super::needs_shell_quoting(c),
"POSIX: {c:?} must need quoting"
);
#[cfg(windows)]
assert!(
!super::needs_shell_quoting(c),
"Windows: {c:?} must not need quoting (path separator / 8.3 short name)"
);
}
}
#[test]
fn warn_if_path_needs_user_edit_flags_leading_dash() {
let mut stderr = Vec::new();
super::warn_if_path_needs_user_edit(Some(Path::new("-relative.toml")), &mut stderr);
let stderr = String::from_utf8(stderr).unwrap();
assert!(
stderr.contains("starts with `-`"),
"missing leading-dash warning:\n{stderr}"
);
}
#[test]
fn warn_if_path_needs_user_edit_quiet_for_clean_paths() {
let mut stderr = Vec::new();
super::warn_if_path_needs_user_edit(
Some(Path::new("/etc/linesmith/config.toml")),
&mut stderr,
);
assert!(
stderr.is_empty(),
"false-positive warning:\n{}",
String::from_utf8_lossy(&stderr)
);
}
#[test]
fn overwrite_policy_constructors_lock_the_bit_mapping() {
let presets_off = super::OverwritePolicy::presets(false);
assert!(!presets_off.skip_prompt);
assert!(!presets_off.clobber_backup);
let presets_on = super::OverwritePolicy::presets(true);
assert!(presets_on.skip_prompt);
assert!(presets_on.clobber_backup);
let init = super::OverwritePolicy::init();
assert!(!init.skip_prompt, "init must always prompt");
assert!(init.clobber_backup, "init must always clobber .bak");
}
#[test]
fn presets_apply_without_resolvable_path_errors() {
let env = CliEnv {
home: None,
..CliEnv::for_tests()
};
let (code, _stdout, stderr) = run_cli_main(&["presets", "apply", "minimal"], b"", &env);
assert_eq!(code, 1);
assert!(stderr.contains("cannot resolve"));
}
#[test]
fn doctor_subcommand_dispatches_via_cli_main() {
let (_code, stdout, _stderr) = run_cli_main(&["doctor"], b"", &CliEnv::for_tests());
assert!(stdout.contains("linesmith doctor"), "{stdout}");
assert!(stdout.contains("Self"), "{stdout}");
}
fn run_doctor(env: crate::doctor::DoctorEnv, plain: bool) -> (u8, String, String) {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = doctor_action_with_env(env, plain, &mut stdout, &mut stderr);
(
code,
String::from_utf8(stdout).expect("utf8 stdout"),
String::from_utf8(stderr).expect("utf8 stderr"),
)
}
#[test]
fn doctor_action_with_healthy_env_renders_and_exits_zero() {
let (code, stdout, stderr) = run_doctor(crate::doctor::DoctorEnv::healthy(), false);
assert_eq!(code, 0, "healthy env must exit 0; stderr was: {stderr}");
assert!(stdout.contains("linesmith doctor"), "{stdout}");
assert!(stdout.contains("Environment"), "{stdout}");
assert!(stdout.contains("Config"), "{stdout}");
assert!(stdout.contains("Self"), "{stdout}");
assert!(stdout.contains("Exit: 0"), "{stdout}");
assert!(stderr.is_empty(), "{stderr}");
}
#[test]
fn doctor_action_with_home_unset_exits_one() {
let mut env = crate::doctor::DoctorEnv::healthy();
env.home_env = crate::doctor::EnvVarState::Unset;
let (code, _stdout, _stderr) = run_doctor(env, false);
assert_eq!(code, 1);
}
#[test]
fn doctor_plain_output_is_ascii_only() {
let (code, stdout, _stderr) = run_doctor(crate::doctor::DoctorEnv::healthy(), true);
assert_eq!(code, 0);
assert!(stdout.is_ascii(), "plain output had non-ASCII:\n{stdout}");
assert!(stdout.contains("OK"), "{stdout}");
}
#[test]
fn doctor_default_output_includes_unicode_glyph() {
let (code, stdout, _stderr) = run_doctor(crate::doctor::DoctorEnv::healthy(), false);
assert_eq!(code, 0);
assert!(stdout.contains('✓'), "{stdout}");
}
#[test]
fn doctor_unknown_flag_exits_two() {
let (code, _stdout, stderr) =
run_cli_main(&["doctor", "--bogus"], b"", &CliEnv::for_tests());
assert_eq!(code, 2);
assert!(
stderr.contains("bogus") || stderr.contains("Try --help"),
"{stderr}"
);
}
#[test]
fn doctor_config_override_to_missing_explicit_path_fails() {
let mut env = crate::doctor::DoctorEnv::healthy();
env.config = crate::doctor::DoctorConfigSnapshot {
cli_override: Some(PathBuf::from("/nonexistent.toml")),
resolved: Some(crate::config::ConfigPath {
path: PathBuf::from("/nonexistent.toml"),
explicit: true,
}),
read: crate::doctor::ConfigReadOutcome::NotFound {
path: PathBuf::from("/nonexistent.toml"),
explicit: true,
},
plugin_dirs: Vec::new(),
known_segment_ids: crate::doctor::DoctorConfigSnapshot::built_in_segment_ids(),
known_theme_names: crate::doctor::DoctorConfigSnapshot::built_in_theme_names(),
};
let (code, stdout, _stderr) = run_doctor(env, false);
assert_eq!(code, 1, "explicit missing config must FAIL → exit 1");
assert!(
stdout.contains("/nonexistent.toml"),
"report should name the missing path:\n{stdout}"
);
}
#[test]
fn doctor_action_threads_cli_override_through_from_process() {
let dir = tempdir();
let cfg_path = dir.path().join("unique-marker.toml");
std::fs::write(&cfg_path, r#"theme = "default""#).expect("write tempfile");
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let _code = doctor_action(true, Some(cfg_path.clone()), &mut stdout, &mut stderr);
let stdout_str = String::from_utf8(stdout).expect("utf8 stdout");
assert!(
stdout_str.contains(&cfg_path.display().to_string()),
"report must name the supplied --config path; \
got stdout (override probably dropped before from_process):\n{stdout_str}",
);
assert!(
stdout_str.contains("Config file:"),
"expected `Config file:` PASS line:\n{stdout_str}",
);
}
#[test]
fn doctor_stdout_failure_logs_to_stderr_and_returns_report_exit_code() {
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
let mut stderr = Vec::new();
let code = doctor_action_with_env(
crate::doctor::DoctorEnv::healthy(),
false,
&mut FailingWriter,
&mut stderr,
);
assert_eq!(
code, 0,
"all-PASS report exits 0 even when stdout is broken"
);
let stderr_str = String::from_utf8(stderr).expect("utf8");
assert!(
stderr_str.contains("linesmith: doctor:"),
"expected doctor diagnostic on stderr, got: {stderr_str}"
);
}
fn tempdir() -> TempDir {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let base = std::env::temp_dir().join(format!(
"linesmith-driver-test-{}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
std::fs::create_dir_all(&base).expect("mkdir");
TempDir(base)
}
}