#![cfg_attr(
not(test),
deny(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)
)]
#![cfg_attr(test, allow(clippy::disallowed_methods))]
#![allow(
clippy::empty_line_after_doc_comments,
reason = "the blank line between the crate-level attribute block and the //! module docs below is intentional"
)]
#![allow(
clippy::must_use_candidate,
reason = "prompter has many small value-returning helpers; blanket #[must_use] would add noise without catching real misuse"
)]
pub mod cli;
pub mod config;
pub mod error;
pub mod profile;
pub mod render;
pub mod completions;
pub mod scaffold;
pub mod doctor;
pub use cli::*;
pub use config::*;
pub use profile::*;
pub use render::*;
pub use error::PrompterError;
pub use scaffold::*;
use serde::Serialize;
use chrono::Local;
use clap::Parser;
use colored::Colorize;
use is_terminal::IsTerminal;
use std::env;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub use tftio_cli_common::{AgentSubcommand, MetaCommand};
use tftio_cli_common::{JsonOutput, render_response};
#[must_use]
pub fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('r') => out.push('\r'),
Some('"') => out.push('"'),
Some('\\') | None => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
}
} else {
out.push(c);
}
}
out
}
pub fn parse_args_from(args: Vec<String>) -> Result<AppMode, PrompterError> {
let cli = match Cli::try_parse_from(args) {
Ok(cli) => cli,
Err(err) => match err.kind() {
clap::error::ErrorKind::DisplayHelp => return Ok(AppMode::Help),
clap::error::ErrorKind::DisplayVersion => return Ok(AppMode::Version { json: false }),
_ => return Err(PrompterError::ArgParse(err.to_string())),
},
};
Ok(resolve_app_mode(cli))
}
pub const SYSTEM_BASE_PROFILE: &str = "core.base";
#[must_use]
pub fn resolve_app_mode(cli: Cli) -> AppMode {
match cli.command {
Commands::Meta { command } => match command {
MetaCommand::Version { json } => AppMode::Version { json },
MetaCommand::License => AppMode::License,
MetaCommand::Completions { shell } => AppMode::Completions { shell },
MetaCommand::Doctor { json } => AppMode::Doctor { json },
MetaCommand::Agent { command } => AppMode::Agent { command },
},
Commands::Init => AppMode::Init,
Commands::List => AppMode::List {
config: cli.config,
json: cli.json,
},
Commands::Tree => AppMode::Tree {
config: cli.config,
json: cli.json,
},
Commands::Validate => AppMode::Validate {
config: cli.config,
json: cli.json,
},
Commands::Run {
profiles,
family,
separator,
pre_prompt,
post_prompt,
bare,
} => {
let sep = separator.as_ref().map(|s| unescape(s));
let pre = pre_prompt.as_ref().map(|s| unescape(s));
let post = post_prompt.as_ref().map(|s| unescape(s));
AppMode::Run {
profiles,
family,
separator: sep,
pre_prompt: pre,
post_prompt: post,
framing: Framing::from_bare_flag(bare),
config: cli.config,
json: cli.json,
}
}
Commands::System {
profiles,
separator,
pre_prompt,
post_prompt,
bare,
} => {
let sep = separator.as_ref().map(|s| unescape(s));
let pre = pre_prompt.as_ref().map(|s| unescape(s));
let post = post_prompt.as_ref().map(|s| unescape(s));
let mut all = Vec::with_capacity(profiles.len() + 1);
all.push(SYSTEM_BASE_PROFILE.to_string());
all.extend(profiles);
AppMode::Run {
profiles: all,
family: None,
separator: sep,
pre_prompt: pre,
post_prompt: post,
framing: Framing::from_bare_flag(bare),
config: cli.config,
json: cli.json,
}
}
}
}
fn home_dir() -> Result<PathBuf, PrompterError> {
dirs::home_dir().ok_or(PrompterError::HomeNotSet)
}
fn config_path() -> Result<PathBuf, PrompterError> {
Ok(home_dir()?.join(".config/prompter/config.toml"))
}
fn library_dir() -> Result<PathBuf, PrompterError> {
Ok(home_dir()?.join(".local/prompter/library"))
}
fn resolve_primary_config_path(path: &Path) -> Result<PathBuf, PrompterError> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
env::current_dir()
.map_err(PrompterError::WorkingDir)
.map(|cwd| cwd.join(path))
}
}
fn is_terminal() -> bool {
std::io::stdout().is_terminal()
}
fn default_pre_prompt() -> String {
"You are an LLM coding agent. Here are invariants that you must adhere to. Please respond with 'Got it' when you have studied these and understand them. At that point, the operator will give you further instructions. You are *not* to do anything to the contents of this directory until you have been explicitly asked to, by the operator.\n\n".to_string()
}
fn default_post_prompt() -> String {
"Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist.".to_string()
}
fn format_system_prefix() -> String {
let date = Local::now().format("%Y-%m-%d").to_string();
let os = env::consts::OS;
let arch = env::consts::ARCH;
if is_terminal() {
format!(
"🗓️ Today is {}, and you are running on a {}/{} system.\n\n",
date.bright_cyan(),
arch.bright_green(),
os.bright_green()
)
} else {
format!("Today is {date}, and you are running on a {arch}/{os} system.\n\n")
}
}
fn success_message(msg: &str) -> String {
if is_terminal() {
format!("✅ {}", msg.bright_green())
} else {
msg.to_string()
}
}
fn info_message(msg: &str) -> String {
if is_terminal() {
format!("ℹ️ {}", msg.bright_blue())
} else {
msg.to_string()
}
}
fn read_config_with_path(path: &Path) -> Result<String, PrompterError> {
fs::read_to_string(path).map_err(|source| PrompterError::Io {
path: path.to_path_buf(),
source,
})
}
fn resolve_config_path(config_override: Option<&Path>) -> Result<PathBuf, PrompterError> {
config_override.map_or_else(config_path, resolve_primary_config_path)
}
pub fn run_list_stdout(
config_override: Option<&Path>,
output: JsonOutput,
) -> Result<(), PrompterError> {
let (_cfg_path, cfg) = load_bundle(config_override)?;
list_profiles(&cfg, output, io::stdout())?;
Ok(())
}
#[derive(Debug, Serialize)]
struct ValidateOutput {
valid: bool,
}
pub fn run_validate_stdout(
config_override: Option<&Path>,
output: JsonOutput,
) -> Result<(), PrompterError> {
let (_cfg_path, cfg) = load_bundle(config_override)?;
validate(&cfg)?;
if output.is_json() {
let data = serde_json::to_value(ValidateOutput { valid: true })?;
println!(
"{}",
render_response("validate", JsonOutput::Json, data, String::new())
);
}
Ok(())
}
#[derive(Debug, Serialize)]
struct FragmentOutput {
path: String,
content: String,
}
#[derive(Debug, Serialize)]
struct RenderOutput {
profile: String,
pre_prompt: String,
system_info: String,
fragments: Vec<FragmentOutput>,
}
pub fn available_profiles(config_override: Option<&Path>) -> Result<Vec<String>, PrompterError> {
let (_cfg_path, cfg) = load_bundle(config_override)?;
let mut names: Vec<String> = cfg.profiles.keys().cloned().collect();
names.sort();
Ok(names)
}
#[cfg(test)]
#[allow(clippy::wildcard_imports)]
mod tests {
use super::*;
#[allow(unused_imports)]
use std::collections::HashSet;
use std::io::Write;
fn mk_tmp(prefix: &str) -> PathBuf {
let mut p = env::temp_dir();
let unique = format!(
"{}_{}_{}",
prefix,
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
);
p.push(unique);
p
}
fn cfg_with_lib<I>(profiles: I, lib: &Path, post_prompt: Option<&str>) -> Config
where
I: IntoIterator<Item = (&'static str, Vec<&'static str>)>,
{
let profiles = profiles
.into_iter()
.map(|(name, deps)| {
(
name.to_string(),
ProfileDef {
deps: deps.into_iter().map(String::from).collect(),
library_root: lib.to_path_buf(),
},
)
})
.collect();
Config {
profiles,
post_prompt: post_prompt.map(String::from),
}
}
#[test]
fn test_unescape() {
assert_eq!(unescape("a\\nb\\t\\\"\\\\c"), "a\nb\t\"\\c");
assert_eq!(unescape("line1\\rline2"), "line1\rline2");
assert_eq!(unescape("noesc"), "noesc");
}
#[test]
fn test_parse_config_file_errors() {
let err = parse_config_file("not valid toml {{{")
.unwrap_err()
.to_string();
assert!(err.contains("Invalid TOML"), "err={err}");
let err = parse_config_file("[p]\ndepends_on = \"x\"\n")
.unwrap_err()
.to_string();
assert!(err.contains("`depends_on`"), "err={err}");
let err = parse_config_file("import = \"oops\"\n")
.unwrap_err()
.to_string();
assert!(err.contains("`import`"), "err={err}");
}
#[test]
fn test_validate_success_and_unknowns() {
let lib = mk_tmp("prompter_validate_ok");
fs::create_dir_all(&lib).unwrap();
fs::write(lib.join("a.md"), b"A").unwrap();
fs::write(lib.join("b.md"), b"B").unwrap();
let cfg = cfg_with_lib(
[("p1", vec!["a.md"]), ("p2", vec!["p1", "b.md"])],
&lib,
None,
);
assert!(validate(&cfg).is_ok());
let cfg2 = cfg_with_lib([("root", vec!["nope"])], &lib, None);
let err = validate(&cfg2).unwrap_err().to_string();
assert!(err.contains("Unknown profile"));
}
#[test]
fn test_resolve_errors_and_dedup() {
let lib = mk_tmp("prompter_resolve_errs");
fs::create_dir_all(&lib).unwrap();
let cfg = cfg_with_lib([("root", vec!["missing.md"])], &lib, None);
let mut seen = HashSet::new();
let mut stack = Vec::new();
let mut out = Vec::new();
let err = resolve_profile("root", &cfg, &mut seen, &mut stack, &mut out).unwrap_err();
match err {
ResolveError::MissingFile(_, p) => assert_eq!(p, "root"),
_ => panic!("expected missing file"),
}
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/b.md"), b"X").unwrap();
let cfg2 = cfg_with_lib(
[("A", vec!["a/b.md"]), ("B", vec!["A", "a/b.md"])],
&lib,
None,
);
let mut seen = HashSet::new();
let mut stack = Vec::new();
let mut out = Vec::new();
resolve_profile("B", &cfg2, &mut seen, &mut stack, &mut out).unwrap();
assert_eq!(out.len(), 1);
}
#[test]
fn test_parse_args_errors() {
let args = vec!["prompter".into(), "--bogus".into()];
let err = parse_args_from(args).unwrap_err().to_string();
assert!(err.contains("unexpected argument"));
let args = vec!["prompter".into()];
let err = parse_args_from(args).unwrap_err().to_string();
assert!(err.contains("Usage:") || err.contains("COMMAND"));
}
#[test]
fn test_list_profiles_order() {
let lib = mk_tmp("prompter_list_order");
fs::create_dir_all(&lib).unwrap();
let cfg = cfg_with_lib([("b", vec![]), ("a", vec![])], &lib, None);
let mut out = Vec::new();
super::list_profiles(&cfg, JsonOutput::Text, &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), "a\nb\n");
}
#[test]
fn test_validate_cycle_detected() {
let lib = mk_tmp("prompter_cycle");
fs::create_dir_all(&lib).unwrap();
let cfg = cfg_with_lib([("A", vec!["B"]), ("B", vec!["A"])], &lib, None);
let err = validate(&cfg).unwrap_err().to_string();
assert!(err.contains("Cycle detected"));
}
#[test]
fn test_parse_config_file_flattens_dotted_tables() {
let cfg = r#"
[profile.x]
depends_on = [
"a/b.md",
"c/d.md",
"e/f.md",
]
"#;
let parsed = parse_config_file(cfg).unwrap();
assert_eq!(parsed.profiles.get("profile.x").unwrap().len(), 3);
}
#[test]
fn test_render_to_writer_basic() {
let lib = mk_tmp("prompter_render_to_writer");
fs::create_dir_all(lib.join("a")).unwrap();
fs::create_dir_all(lib.join("f")).unwrap();
fs::write(lib.join("a/x.md"), b"AX\n").unwrap();
fs::write(lib.join("f/y.md"), b"FY\n").unwrap();
let cfg = cfg_with_lib(
[
("child", vec!["a/x.md"]),
("root", vec!["child", "f/y.md", "a/x.md"]),
],
&lib,
None,
);
let mut out = Vec::new();
super::render_to_writer(
&cfg,
&mut out,
&["root".to_string()],
None,
Some("\n--\n"),
None,
None,
Framing::Full,
JsonOutput::Text,
)
.unwrap();
let output_str = String::from_utf8(out).unwrap();
assert!(output_str.starts_with("You are an LLM coding agent."));
assert!(output_str.contains("Today is "));
assert!(output_str.contains(", and you are running on a "));
assert!(output_str.contains(" system.\n\n"));
assert!(output_str.contains("AX\n"));
assert!(output_str.contains("\n--\n"));
assert!(output_str.contains("FY\n"));
assert!(output_str.ends_with(
"Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist."
));
}
#[test]
fn test_render_to_writer_bare_omits_framing() {
let lib = mk_tmp("prompter_render_bare");
fs::create_dir_all(lib.join("a")).unwrap();
fs::create_dir_all(lib.join("f")).unwrap();
fs::write(lib.join("a/x.md"), b"AX\n").unwrap();
fs::write(lib.join("f/y.md"), b"FY\n").unwrap();
let cfg = cfg_with_lib(
[
("child", vec!["a/x.md"]),
("root", vec!["child", "f/y.md", "a/x.md"]),
],
&lib,
Some("Config post-prompt"),
);
let mut out = Vec::new();
super::render_to_writer(
&cfg,
&mut out,
&["root".to_string()],
None,
Some("\n--\n"),
None,
None,
Framing::Bare,
JsonOutput::Text,
)
.unwrap();
let output_str = String::from_utf8(out).unwrap();
assert!(!output_str.starts_with("You are an LLM coding agent."));
assert!(!output_str.contains("Today is "));
assert!(!output_str.contains("Config post-prompt"));
assert!(!output_str.contains(
"Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist."
));
assert!(output_str.starts_with("AX\n"));
assert!(output_str.contains("\n--\n"));
assert!(output_str.contains("FY\n"));
assert_eq!(output_str.matches("AX\n").count(), 1);
}
#[test]
fn test_render_to_writer_bare_honors_explicit_pre_post() {
let lib = mk_tmp("prompter_render_bare_explicit");
fs::create_dir_all(lib.join("a")).unwrap();
fs::create_dir_all(lib.join("f")).unwrap();
fs::write(lib.join("a/x.md"), b"AX\n").unwrap();
fs::write(lib.join("f/y.md"), b"FY\n").unwrap();
let cfg = cfg_with_lib(
[("child", vec!["a/x.md"]), ("root", vec!["child", "f/y.md"])],
&lib,
Some("Config post-prompt"),
);
let mut out = Vec::new();
super::render_to_writer(
&cfg,
&mut out,
&["root".to_string()],
None,
None,
Some("EXPLICIT-PRE"),
Some("EXPLICIT-POST"),
Framing::Bare,
JsonOutput::Text,
)
.unwrap();
let output_str = String::from_utf8(out).unwrap();
assert!(output_str.starts_with("EXPLICIT-PRE"));
assert!(output_str.ends_with("EXPLICIT-POST"));
assert!(!output_str.contains("Config post-prompt"));
assert!(!output_str.contains("Today is "));
assert!(output_str.contains("AX\n"));
assert!(output_str.contains("FY\n"));
}
#[test]
fn test_render_to_writer_custom_pre_prompt() {
let lib = mk_tmp("prompter_render_custom_pre");
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/x.md"), b"Content\n").unwrap();
let cfg = cfg_with_lib([("test", vec!["a/x.md"])], &lib, None);
let mut out = Vec::new();
super::render_to_writer(
&cfg,
&mut out,
&["test".to_string()],
None,
None,
Some("Custom pre-prompt\n\n"),
None,
Framing::Full,
JsonOutput::Text,
)
.unwrap();
let output_str = String::from_utf8(out).unwrap();
assert!(output_str.starts_with("Custom pre-prompt\n\n"));
assert!(output_str.contains("Today is "));
assert!(output_str.contains("Content\n"));
assert!(output_str.ends_with(
"Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist."
));
}
#[test]
fn test_render_to_writer_custom_post_prompt() {
let lib = mk_tmp("prompter_render_custom_post");
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/x.md"), b"Content\n").unwrap();
let cfg = cfg_with_lib(
[("test", vec!["a/x.md"])],
&lib,
Some("Custom config post-prompt"),
);
let mut out = Vec::new();
super::render_to_writer(
&cfg,
&mut out,
&["test".to_string()],
None,
None,
None,
None,
Framing::Full,
JsonOutput::Text,
)
.unwrap();
let output_str = String::from_utf8(out).unwrap();
assert!(output_str.ends_with("Custom config post-prompt"));
let mut out2 = Vec::new();
super::render_to_writer(
&cfg,
&mut out2,
&["test".to_string()],
None,
None,
None,
Some("CLI post-prompt"),
Framing::Full,
JsonOutput::Text,
)
.unwrap();
let output_str2 = String::from_utf8(out2).unwrap();
assert!(output_str2.ends_with("CLI post-prompt"));
}
#[test]
fn test_render_multiple_profiles_with_deduplication() {
let lib = mk_tmp("prompter_multi_profile_dedup");
fs::create_dir_all(lib.join("shared")).unwrap();
fs::create_dir_all(lib.join("a")).unwrap();
fs::create_dir_all(lib.join("b")).unwrap();
fs::write(lib.join("shared/common.md"), b"COMMON\n").unwrap();
fs::write(lib.join("a/specific.md"), b"A_SPECIFIC\n").unwrap();
fs::write(lib.join("b/specific.md"), b"B_SPECIFIC\n").unwrap();
let cfg = cfg_with_lib(
[
("profile_a", vec!["shared/common.md", "a/specific.md"]),
("profile_b", vec!["shared/common.md", "b/specific.md"]),
],
&lib,
None,
);
let mut out = Vec::new();
super::render_to_writer(
&cfg,
&mut out,
&["profile_a".to_string(), "profile_b".to_string()],
None,
Some("\n---\n"),
None,
None,
Framing::Full,
JsonOutput::Text,
)
.unwrap();
let output_str = String::from_utf8(out).unwrap();
let common_count = output_str.matches("COMMON").count();
assert_eq!(
common_count, 1,
"Common file should appear exactly once, found {common_count}"
);
assert!(output_str.contains("A_SPECIFIC"));
assert!(output_str.contains("B_SPECIFIC"));
let common_pos = output_str.find("COMMON").unwrap();
let a_pos = output_str.find("A_SPECIFIC").unwrap();
let b_pos = output_str.find("B_SPECIFIC").unwrap();
assert!(common_pos < a_pos);
assert!(a_pos < b_pos);
}
#[test]
fn test_family_variant_substitution_fallback_and_neutral_dedup() {
let lib = mk_tmp("prompter_family_substitution");
fs::create_dir_all(lib.join("general/families/gpt")).unwrap();
fs::write(lib.join("general/rules.md"), b"NEUTRAL_RULES\n").unwrap();
fs::write(lib.join("general/fallback.md"), b"FALLBACK\n").unwrap();
fs::write(lib.join("general/families/gpt/rules.md"), b"GPT_RULES\n").unwrap();
let cfg = cfg_with_lib(
[
("first", vec!["general/rules.md", "general/fallback.md"]),
("second", vec!["general/rules.md"]),
],
&lib,
None,
);
let family = FamilyName::new("gpt").unwrap();
let mut family_output = Vec::new();
super::render_to_writer(
&cfg,
&mut family_output,
&["first".to_string(), "second".to_string()],
Some(&family),
None,
None,
None,
Framing::Bare,
JsonOutput::Text,
)
.unwrap();
let family_output = String::from_utf8(family_output).unwrap();
assert_eq!(family_output.matches("GPT_RULES").count(), 1);
assert!(!family_output.contains("NEUTRAL_RULES"));
assert!(family_output.contains("FALLBACK"));
let mut neutral_output = Vec::new();
super::render_to_writer(
&cfg,
&mut neutral_output,
&["first".to_string(), "second".to_string()],
None,
None,
None,
None,
Framing::Bare,
JsonOutput::Text,
)
.unwrap();
let neutral_output = String::from_utf8(neutral_output).unwrap();
assert_eq!(neutral_output.matches("NEUTRAL_RULES").count(), 1);
assert!(!neutral_output.contains("GPT_RULES"));
assert!(neutral_output.contains("FALLBACK"));
}
#[test]
fn test_validate_rejects_orphan_family_variant() {
let lib = mk_tmp("prompter_family_orphan");
fs::create_dir_all(lib.join("general/families/gpt")).unwrap();
fs::write(lib.join("general/rules.md"), b"NEUTRAL_RULES\n").unwrap();
fs::write(lib.join("general/families/gpt/rules.md"), b"GPT_RULES\n").unwrap();
let cfg = cfg_with_lib([("root", vec!["general/rules.md"])], &lib, None);
assert!(validate(&cfg).is_ok());
let orphan = lib.join("general/families/gpt/orphan.md");
fs::write(&orphan, b"ORPHAN\n").unwrap();
let error = validate(&cfg).unwrap_err().to_string();
assert!(error.contains("Orphan family variant"), "error: {error}");
assert!(
error.contains(&orphan.display().to_string()),
"error: {error}"
);
}
#[test]
fn test_parse_config_file_with_post_prompt() {
let cfg = r#"
post_prompt = "Custom post prompt from config"
[profile]
depends_on = ["file.md"]
"#;
let parsed = parse_config_file(cfg).unwrap();
assert_eq!(
parsed.post_prompt,
Some("Custom post prompt from config".to_string())
);
assert_eq!(parsed.profiles.get("profile").unwrap().len(), 1);
}
#[test]
fn test_expand_tilde() {
let home = env::var("HOME").ok();
if let Some(h) = home {
assert_eq!(
expand_tilde("~/foo/bar").unwrap(),
PathBuf::from(&h).join("foo/bar")
);
assert_eq!(expand_tilde("~").unwrap(), PathBuf::from(&h));
}
assert_eq!(
expand_tilde("/abs/path").unwrap(),
PathBuf::from("/abs/path")
);
assert_eq!(expand_tilde("rel/path").unwrap(), PathBuf::from("rel/path"));
}
#[test]
fn test_load_bundle_single_file() {
let dir = mk_tmp("prompter_bundle_single");
fs::create_dir_all(dir.join("library/a")).unwrap();
fs::write(dir.join("library/a/x.md"), b"AX").unwrap();
fs::write(
dir.join("config.toml"),
r#"
[root]
depends_on = ["a/x.md"]
"#,
)
.unwrap();
let cfg = load_config_bundle(&dir.join("config.toml"), None).unwrap();
assert_eq!(cfg.profiles.len(), 1);
let root = cfg.profiles.get("root").unwrap();
assert_eq!(root.deps, vec!["a/x.md"]);
assert_eq!(
root.library_root,
fs::canonicalize(dir.join("library")).unwrap()
);
}
#[test]
fn test_load_bundle_imports_and_dedup_across_libraries() {
let primary_dir = mk_tmp("prompter_bundle_primary");
let imported_dir = mk_tmp("prompter_bundle_import");
fs::create_dir_all(primary_dir.join("library/p")).unwrap();
fs::write(primary_dir.join("library/p/primary.md"), b"P").unwrap();
fs::create_dir_all(imported_dir.join("library/i")).unwrap();
fs::write(imported_dir.join("library/i/imported.md"), b"I").unwrap();
fs::write(
imported_dir.join("config.toml"),
r#"
[team.base]
depends_on = ["i/imported.md"]
"#,
)
.unwrap();
let primary_cfg = format!(
r#"
import = ["{}"]
[my.local]
depends_on = ["team.base", "p/primary.md"]
"#,
imported_dir.join("config.toml").display()
);
fs::write(primary_dir.join("config.toml"), primary_cfg).unwrap();
let cfg = load_config_bundle(&primary_dir.join("config.toml"), None).unwrap();
assert_eq!(cfg.profiles.len(), 2);
assert_eq!(
cfg.profiles.get("team.base").unwrap().library_root,
fs::canonicalize(imported_dir.join("library")).unwrap()
);
assert_eq!(
cfg.profiles.get("my.local").unwrap().library_root,
fs::canonicalize(primary_dir.join("library")).unwrap()
);
let mut seen = HashSet::new();
let mut stack = Vec::new();
let mut out = Vec::new();
resolve_profile("my.local", &cfg, &mut seen, &mut stack, &mut out).unwrap();
assert_eq!(out.len(), 2);
}
#[test]
fn test_load_bundle_duplicate_profile_name_across_imports() {
let primary_dir = mk_tmp("prompter_bundle_dup_primary");
let imported_dir = mk_tmp("prompter_bundle_dup_import");
fs::create_dir_all(primary_dir.join("library")).unwrap();
fs::create_dir_all(imported_dir.join("library")).unwrap();
fs::write(
imported_dir.join("config.toml"),
"\n[clash]\ndepends_on = []\n",
)
.unwrap();
let primary_cfg = format!(
"\nimport = [\"{}\"]\n\n[clash]\ndepends_on = []\n",
imported_dir.join("config.toml").display()
);
fs::write(primary_dir.join("config.toml"), primary_cfg).unwrap();
let err = load_config_bundle(&primary_dir.join("config.toml"), None)
.unwrap_err()
.to_string();
assert!(err.contains("Duplicate profile `clash`"), "err={err}");
}
#[test]
fn test_load_bundle_import_cycle() {
let a_dir = mk_tmp("prompter_cycle_a");
let b_dir = mk_tmp("prompter_cycle_b");
fs::create_dir_all(a_dir.join("library")).unwrap();
fs::create_dir_all(b_dir.join("library")).unwrap();
let a_path = a_dir.join("config.toml");
let b_path = b_dir.join("config.toml");
fs::write(&a_path, format!("import = [\"{}\"]\n", b_path.display())).unwrap();
fs::write(&b_path, format!("import = [\"{}\"]\n", a_path.display())).unwrap();
let err = load_config_bundle(&a_path, None).unwrap_err().to_string();
assert!(err.contains("Import cycle"), "err={err}");
}
#[test]
fn test_load_bundle_explicit_library_key() {
let dir = mk_tmp("prompter_bundle_explicit_lib");
fs::create_dir_all(dir.join("alt_library/sub")).unwrap();
fs::write(dir.join("alt_library/sub/x.md"), b"X").unwrap();
fs::write(
dir.join("config.toml"),
r#"
library = "alt_library"
[p]
depends_on = ["sub/x.md"]
"#,
)
.unwrap();
let cfg = load_config_bundle(&dir.join("config.toml"), None).unwrap();
let expected = fs::canonicalize(dir.join("alt_library")).unwrap();
assert_eq!(cfg.profiles.get("p").unwrap().library_root, expected);
}
#[test]
fn test_load_bundle_import_post_prompt_only_from_primary() {
let primary_dir = mk_tmp("prompter_pp_primary");
let imported_dir = mk_tmp("prompter_pp_import");
fs::create_dir_all(primary_dir.join("library")).unwrap();
fs::create_dir_all(imported_dir.join("library")).unwrap();
fs::write(
imported_dir.join("config.toml"),
r#"
post_prompt = "from imported"
"#,
)
.unwrap();
let primary_cfg = format!(
r#"
import = ["{}"]
post_prompt = "from primary"
"#,
imported_dir.join("config.toml").display()
);
fs::write(primary_dir.join("config.toml"), primary_cfg).unwrap();
let cfg = load_config_bundle(&primary_dir.join("config.toml"), None).unwrap();
assert_eq!(cfg.post_prompt.as_deref(), Some("from primary"));
fs::write(
primary_dir.join("config.toml"),
format!(
r#"
import = ["{}"]
"#,
imported_dir.join("config.toml").display()
),
)
.unwrap();
let cfg2 = load_config_bundle(&primary_dir.join("config.toml"), None).unwrap();
assert!(cfg2.post_prompt.is_none());
}
fn expect_run(args: Vec<String>) -> AppMode {
let mode = parse_args_from(args).unwrap();
assert!(matches!(mode, AppMode::Run { .. }), "expected run");
mode
}
#[test]
fn parse_args_run_with_separator() {
let args = vec![
"prompter".into(),
"run".into(),
"--separator".into(),
"\\n--\\n".into(),
"profile".into(),
];
let AppMode::Run {
profiles,
family,
separator,
pre_prompt,
post_prompt,
framing,
config,
json,
} = expect_run(args)
else {
unreachable!()
};
assert_eq!(profiles, vec!["profile".to_string()]);
assert_eq!(family, None);
assert_eq!(separator, Some("\n--\n".into()));
assert_eq!(pre_prompt, None);
assert_eq!(post_prompt, None);
assert_eq!(framing, Framing::Full);
assert!(config.is_none());
assert!(!json);
}
#[test]
fn parse_args_run_with_family() {
let args = vec![
"prompter".into(),
"run".into(),
"--family".into(),
"gpt".into(),
"profile".into(),
];
let AppMode::Run {
profiles, family, ..
} = expect_run(args)
else {
unreachable!()
};
assert_eq!(profiles, vec!["profile".to_string()]);
assert_eq!(family, Some(FamilyName::new("gpt").unwrap()));
}
#[test]
fn parse_args_rejects_family_path_traversal() {
let args = vec![
"prompter".into(),
"run".into(),
"--family".into(),
"../gpt".into(),
"profile".into(),
];
let error = parse_args_from(args).unwrap_err().to_string();
assert!(error.contains("family name must be one non-empty path component"));
}
#[test]
fn parse_args_run_with_pre_prompt() {
let args = vec![
"prompter".into(),
"run".into(),
"--pre-prompt".into(),
"Custom pre-prompt".into(),
"profile".into(),
];
let AppMode::Run {
profiles,
separator,
pre_prompt,
..
} = expect_run(args)
else {
unreachable!()
};
assert_eq!(profiles, vec!["profile".to_string()]);
assert_eq!(separator, None);
assert_eq!(pre_prompt, Some("Custom pre-prompt".into()));
}
#[test]
fn parse_args_run_with_bare_flag() {
let args = vec![
"prompter".into(),
"run".into(),
"--bare".into(),
"profile".into(),
];
let AppMode::Run {
profiles, framing, ..
} = expect_run(args)
else {
unreachable!()
};
assert_eq!(profiles, vec!["profile".to_string()]);
assert_eq!(framing, Framing::Bare);
}
#[test]
fn parse_args_system_with_bare_flag() {
let args = vec![
"prompter".into(),
"system".into(),
"--bare".into(),
"extra".into(),
];
let AppMode::Run {
profiles, framing, ..
} = expect_run(args)
else {
unreachable!()
};
assert_eq!(
profiles,
vec![SYSTEM_BASE_PROFILE.to_string(), "extra".to_string()]
);
assert_eq!(framing, Framing::Bare);
}
#[test]
fn parse_args_run_with_multiple_profiles() {
let args = vec![
"prompter".into(),
"run".into(),
"profile1".into(),
"profile2".into(),
"profile3.nested".into(),
];
let AppMode::Run { profiles, .. } = expect_run(args) else {
unreachable!()
};
assert_eq!(
profiles,
vec![
"profile1".to_string(),
"profile2".to_string(),
"profile3.nested".to_string(),
]
);
}
#[test]
fn parse_args_bare_subcommands() {
let args = vec!["prompter".into(), "list".into()];
assert!(matches!(
parse_args_from(args).unwrap(),
AppMode::List {
config: None,
json: false
}
));
let args = vec!["prompter".into(), "validate".into()];
assert!(matches!(
parse_args_from(args).unwrap(),
AppMode::Validate {
config: None,
json: false
}
));
let args = vec!["prompter".into(), "init".into()];
assert!(matches!(parse_args_from(args).unwrap(), AppMode::Init));
let args = vec!["prompter".into(), "meta".into(), "version".into()];
assert!(matches!(
parse_args_from(args).unwrap(),
AppMode::Version { json: false }
));
}
#[test]
fn parse_args_config_before_subcommand() {
let args = vec![
"prompter".into(),
"--config".into(),
"custom/config.toml".into(),
"list".into(),
];
let AppMode::List { config, json } = parse_args_from(args).unwrap() else {
panic!("expected list mode");
};
assert_eq!(config, Some(PathBuf::from("custom/config.toml")));
assert!(!json);
}
#[test]
fn parse_args_config_after_run_subcommand() {
let args = vec![
"prompter".into(),
"run".into(),
"--config".into(),
"custom/config.toml".into(),
"profile".into(),
];
let AppMode::Run { config, json, .. } = parse_args_from(args).unwrap() else {
panic!("expected run mode");
};
assert_eq!(config, Some(PathBuf::from("custom/config.toml")));
assert!(!json);
}
struct FailAfterN {
writes_done: usize,
fail_on: usize,
}
impl Write for FailAfterN {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.writes_done += 1;
if self.writes_done == self.fail_on {
Err(io::Error::other("synthetic write failure"))
} else {
Ok(buf.len())
}
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn test_render_to_writer_write_error_on_separator() {
let lib = mk_tmp("prompter_write_err_sep");
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/x.md"), b"AX").unwrap();
fs::write(lib.join("a/y.md"), b"AY").unwrap();
let cfg = cfg_with_lib([("p", vec!["a/x.md", "a/y.md"])], &lib, None);
let mut w = FailAfterN {
writes_done: 0,
fail_on: 3,
}; let err = super::render_to_writer(
&cfg,
&mut w,
&["p".to_string()],
None,
Some("--"),
None,
None,
Framing::Full,
JsonOutput::Text,
)
.unwrap_err()
.to_string();
assert!(err.contains("Write error"), "err={err}");
}
#[test]
fn test_render_to_writer_write_error_on_file() {
let lib = mk_tmp("prompter_write_err_file");
fs::create_dir_all(lib.join("a")).unwrap();
fs::write(lib.join("a/x.md"), b"AX").unwrap();
let cfg = cfg_with_lib([("p", vec!["a/x.md"])], &lib, None);
let mut w = FailAfterN {
writes_done: 0,
fail_on: 1,
}; let err = super::render_to_writer(
&cfg,
&mut w,
&["p".to_string()],
None,
Some("--"),
None,
None,
Framing::Full,
JsonOutput::Text,
)
.unwrap_err()
.to_string();
assert!(err.contains("Write error"), "err={err}");
}
#[test]
#[ignore = "Fails on CI due to HOME environment variable concurrency issues"]
#[allow(unsafe_code)]
fn test_run_list_and_validate_with_home_injection() {
let home = mk_tmp("prompter_home_unit_ok");
let cfg_dir = home.join(".config/prompter");
let lib_dir = home.join(".local/prompter/library");
fs::create_dir_all(&cfg_dir).unwrap();
fs::create_dir_all(lib_dir.join("a")).unwrap();
fs::create_dir_all(lib_dir.join("f")).unwrap();
fs::write(lib_dir.join("a/x.md"), b"AX\n").unwrap();
fs::write(lib_dir.join("f/y.md"), b"FY\n").unwrap();
let cfg = r#"
[child]
depends_on = ["a/x.md"]
[root]
depends_on = ["child", "f/y.md"]
"#;
fs::write(cfg_dir.join("config.toml"), cfg).unwrap();
let prev_home = env::var("HOME").ok();
unsafe {
env::set_var("HOME", &home);
}
assert!(super::run_validate_stdout(None, JsonOutput::Text).is_ok());
assert!(super::run_list_stdout(None, JsonOutput::Text).is_ok());
if let Some(prev) = prev_home {
unsafe {
env::set_var("HOME", prev);
}
} else {
unsafe {
env::remove_var("HOME");
}
}
}
#[test]
#[allow(unsafe_code)]
fn test_run_validate_with_home_injection_failure() {
let home = mk_tmp("prompter_home_unit_bad");
let cfg_dir = home.join(".config/prompter");
let lib_dir = home.join(".local/prompter/library");
fs::create_dir_all(&cfg_dir).unwrap();
fs::create_dir_all(&lib_dir).unwrap();
let cfg = r#"
[root]
depends_on = ["missing.md", "unknown_profile"]
"#;
fs::write(cfg_dir.join("config.toml"), cfg).unwrap();
let prev_home = env::var("HOME").ok();
unsafe {
env::set_var("HOME", &home);
}
let err = super::run_validate_stdout(None, JsonOutput::Text).unwrap_err();
assert!(
err.to_string().contains("Missing file") && err.to_string().contains("Unknown profile"),
"err={err}"
);
if let Some(prev) = prev_home {
unsafe {
env::set_var("HOME", prev);
}
} else {
unsafe {
env::remove_var("HOME");
}
}
}
#[test]
fn render_to_vec_returns_bytes() {
let result = render_to_vec(&[], None, None);
assert!(result.is_ok() || result.is_err());
}
#[test]
fn available_profiles_returns_sorted() {
let result = available_profiles(None);
if let Ok(profiles) = result {
let mut sorted = profiles.clone();
sorted.sort();
assert_eq!(profiles, sorted);
}
}
}