1pub mod completions;
8
9use chrono::Local;
10use clap::{Parser, Subcommand};
11use colored::Colorize;
12use indicatif::{ProgressBar, ProgressStyle};
13use is_terminal::IsTerminal;
14use serde::{Deserialize, Serialize};
15use std::collections::{HashMap, HashSet};
16use std::env;
17use std::fs;
18use std::io::{self, Write};
19use std::path::{Path, PathBuf};
20
21#[derive(Debug)]
26pub struct Config {
27 pub(crate) profiles: HashMap<String, Vec<String>>,
29 pub(crate) post_prompt: Option<String>,
31}
32
33#[derive(Parser, Debug)]
37#[command(name = "prompter")]
38#[command(about = "Compose reusable prompt snippets from profile definitions")]
39#[command(long_about = None)]
40#[command(version)]
41pub struct Cli {
42 #[command(subcommand)]
44 pub command: Commands,
45
46 #[arg(short = 'c', long, value_name = "FILE", global = true)]
48 pub config: Option<PathBuf>,
49
50 #[arg(short = 'j', long, global = true)]
52 pub json: bool,
53}
54
55#[derive(Subcommand, Debug)]
59pub enum Commands {
60 Version,
62 License,
64 Init,
66 List,
68 Tree,
70 Validate,
72 Run {
74 #[arg(required = true)]
76 profiles: Vec<String>,
77 #[arg(short, long)]
79 separator: Option<String>,
80 #[arg(short = 'p', long)]
82 pre_prompt: Option<String>,
83 #[arg(short = 'P', long)]
85 post_prompt: Option<String>,
86 },
87 Completions {
89 #[arg(value_enum)]
91 shell: clap_complete::Shell,
92 },
93 Doctor,
95}
96
97#[derive(Debug)]
102pub enum AppMode {
103 Run {
105 profiles: Vec<String>,
107 separator: Option<String>,
109 pre_prompt: Option<String>,
111 post_prompt: Option<String>,
113 config: Option<PathBuf>,
115 json: bool,
117 },
118 List {
120 config: Option<PathBuf>,
122 json: bool,
124 },
125 Tree {
127 config: Option<PathBuf>,
129 json: bool,
131 },
132 Validate {
134 config: Option<PathBuf>,
136 json: bool,
138 },
139 Init,
141 Version {
143 json: bool,
145 },
146 License,
148 Help,
150 Completions {
152 shell: clap_complete::Shell,
154 },
155 Doctor {
157 json: bool,
159 },
160}
161
162pub fn parse_args_from(args: Vec<String>) -> Result<AppMode, String> {
180 let cli = match Cli::try_parse_from(args) {
181 Ok(cli) => cli,
182 Err(err) => match err.kind() {
183 clap::error::ErrorKind::DisplayHelp => return Ok(AppMode::Help),
184 clap::error::ErrorKind::DisplayVersion => return Ok(AppMode::Version { json: false }),
185 _ => return Err(err.to_string()),
186 },
187 };
188
189 resolve_app_mode(cli)
190}
191
192pub fn resolve_app_mode(cli: Cli) -> Result<AppMode, String> {
198 match cli.command {
199 Commands::Version => Ok(AppMode::Version { json: cli.json }),
200 Commands::License => Ok(AppMode::License),
201 Commands::Init => Ok(AppMode::Init),
202 Commands::List => Ok(AppMode::List {
203 config: cli.config,
204 json: cli.json,
205 }),
206 Commands::Tree => Ok(AppMode::Tree {
207 config: cli.config,
208 json: cli.json,
209 }),
210 Commands::Validate => Ok(AppMode::Validate {
211 config: cli.config,
212 json: cli.json,
213 }),
214 Commands::Completions { shell } => Ok(AppMode::Completions { shell }),
215 Commands::Doctor => Ok(AppMode::Doctor { json: cli.json }),
216 Commands::Run {
217 profiles,
218 separator,
219 pre_prompt,
220 post_prompt,
221 } => {
222 let sep = separator.as_ref().map(|s| unescape(s));
223 let pre = pre_prompt.as_ref().map(|s| unescape(s));
224 let post = post_prompt.as_ref().map(|s| unescape(s));
225 Ok(AppMode::Run {
226 profiles,
227 separator: sep,
228 pre_prompt: pre,
229 post_prompt: post,
230 config: cli.config,
231 json: cli.json,
232 })
233 }
234 }
235}
236
237#[must_use]
254#[allow(clippy::while_let_on_iterator)]
255pub fn unescape(s: &str) -> String {
256 let mut out = String::with_capacity(s.len());
257 let mut chars = s.chars();
258 while let Some(c) = chars.next() {
259 if c == '\\' {
260 match chars.next() {
261 Some('n') => out.push('\n'),
262 Some('t') => out.push('\t'),
263 Some('r') => out.push('\r'),
264 Some('"') => out.push('"'),
265 Some('\\') | None => out.push('\\'),
266 Some(other) => {
267 out.push('\\');
268 out.push(other);
269 }
270 }
271 } else {
272 out.push(c);
273 }
274 }
275 out
276}
277
278fn home_dir() -> Result<PathBuf, String> {
279 env::var("HOME")
280 .map(PathBuf::from)
281 .map_err(|_| "$HOME not set".into())
282}
283
284fn config_path() -> Result<PathBuf, String> {
285 Ok(home_dir()?.join(".config/prompter/config.toml"))
286}
287
288fn library_dir() -> Result<PathBuf, String> {
289 Ok(home_dir()?.join(".local/prompter/library"))
290}
291
292fn config_path_override(path: &Path) -> Result<PathBuf, String> {
293 let resolved = if path.is_absolute() {
294 path.to_path_buf()
295 } else {
296 env::current_dir()
297 .map_err(|e| format!("Failed to resolve working directory: {e}"))?
298 .join(path)
299 };
300 Ok(resolved)
301}
302
303fn library_dir_for_config(config: &Path) -> Result<PathBuf, String> {
304 let parent = config
305 .parent()
306 .ok_or_else(|| format!("Config path {} has no parent directory", config.display()))?;
307 Ok(parent.join("library"))
308}
309
310fn is_terminal() -> bool {
311 std::io::stdout().is_terminal()
312}
313
314fn default_pre_prompt() -> String {
315 "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()
316}
317
318fn default_post_prompt() -> String {
319 "Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist.".to_string()
320}
321
322fn format_system_prefix() -> String {
323 let date = Local::now().format("%Y-%m-%d").to_string();
324 let os = env::consts::OS;
325 let arch = env::consts::ARCH;
326
327 if is_terminal() {
328 format!(
329 "đī¸ Today is {}, and you are running on a {}/{} system.\n\n",
330 date.bright_cyan(),
331 arch.bright_green(),
332 os.bright_green()
333 )
334 } else {
335 format!("Today is {date}, and you are running on a {arch}/{os} system.\n\n")
336 }
337}
338
339fn success_message(msg: &str) -> String {
340 if is_terminal() {
341 format!("â
{}", msg.bright_green())
342 } else {
343 msg.to_string()
344 }
345}
346
347fn info_message(msg: &str) -> String {
348 if is_terminal() {
349 format!("âšī¸ {}", msg.bright_blue())
350 } else {
351 msg.to_string()
352 }
353}
354
355fn read_config_with_path(path: &Path) -> Result<String, String> {
356 fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))
357}
358
359fn resolve_config_path(config_override: Option<&Path>) -> Result<PathBuf, String> {
360 config_override.map_or_else(config_path, config_path_override)
361}
362
363fn library_path_for_config_override(
364 config_override: Option<&Path>,
365 resolved_config: &Path,
366) -> Result<PathBuf, String> {
367 if config_override.is_some() {
368 library_dir_for_config(resolved_config)
369 } else {
370 library_dir()
371 }
372}
373
374pub fn parse_config_toml(input: &str) -> Result<Config, String> {
392 let mut profiles: HashMap<String, Vec<String>> = HashMap::new();
393 let mut current: Option<String> = None;
394 let mut post_prompt: Option<String> = None;
395
396 let mut collecting = false;
397 let mut buffer = String::new();
398
399 for raw_line in input.lines() {
400 let line = strip_comments(raw_line).trim().to_string();
401 if line.is_empty() {
402 continue;
403 }
404
405 if collecting {
406 buffer.push(' ');
407 buffer.push_str(&line);
408 if contains_closing_bracket_outside_quotes(&buffer) {
409 let items = parse_array_items(&buffer).map_err(|e| {
410 format!(
411 "Invalid depends_on array for [{}]: {}",
412 current.clone().unwrap_or_default(),
413 e
414 )
415 })?;
416 let name = current
417 .clone()
418 .ok_or_else(|| "depends_on outside of a profile section".to_string())?;
419 profiles.insert(name, items);
420 collecting = false;
421 buffer.clear();
422 }
423 continue;
424 }
425
426 if line.starts_with('[') && line.ends_with(']') {
427 let name = line[1..line.len() - 1].trim().to_string();
428 if name.is_empty() {
429 return Err("Empty section name []".into());
430 }
431 current = Some(name);
432 continue;
433 }
434
435 if let Some(eq_pos) = line.find('=') {
436 let key = line[..eq_pos].trim();
437 let value = line[eq_pos + 1..].trim();
438
439 if key == "post_prompt" {
440 if !value.starts_with('"') || !value.ends_with('"') {
441 return Err("post_prompt must be a string".into());
442 }
443 let unquoted = &value[1..value.len() - 1];
444 post_prompt = Some(unescape(unquoted));
445 continue;
446 }
447
448 if key != "depends_on" {
449 continue;
450 }
451 if !value.starts_with('[') {
452 return Err("depends_on must be an array".into());
453 }
454 buffer.clear();
455 buffer.push_str(value);
456 if contains_closing_bracket_outside_quotes(&buffer) {
457 let items = parse_array_items(&buffer).map_err(|e| {
458 format!(
459 "Invalid depends_on array for [{}]: {}",
460 current.clone().unwrap_or_default(),
461 e
462 )
463 })?;
464 let name = current
465 .clone()
466 .ok_or_else(|| "depends_on outside of a profile section".to_string())?;
467 profiles.insert(name, items);
468 buffer.clear();
469 } else {
470 collecting = true;
471 }
472 }
473 }
474
475 Ok(Config {
476 profiles,
477 post_prompt,
478 })
479}
480
481fn strip_comments(s: &str) -> String {
482 let mut out = String::with_capacity(s.len());
483 let mut in_str = false;
484 for c in s.chars() {
485 if c == '"' {
486 out.push(c);
487 in_str = !in_str;
488 continue;
489 }
490 if !in_str && c == '#' {
491 break;
492 }
493 out.push(c);
494 }
495 out
496}
497
498fn contains_closing_bracket_outside_quotes(s: &str) -> bool {
499 let mut in_str = false;
500 for c in s.chars() {
501 if c == '"' {
502 in_str = !in_str;
503 }
504 if !in_str && c == ']' {
505 return true;
506 }
507 }
508 false
509}
510
511fn parse_array_items(s: &str) -> Result<Vec<String>, String> {
512 let mut items = Vec::new();
513 let mut in_str = false;
514 let mut buf = String::new();
515 let mut escaped = false;
516 let mut started = false;
517
518 for c in s.chars() {
519 if !started {
520 if c == '[' {
521 started = true;
522 }
523 continue;
524 }
525 if c == ']' && !in_str {
526 break;
527 }
528 if in_str {
529 if escaped {
530 buf.push(c);
531 escaped = false;
532 continue;
533 }
534 if c == '\\' {
535 escaped = true;
536 continue;
537 }
538 if c == '"' {
539 in_str = false;
540 items.push(buf.clone());
541 buf.clear();
542 continue;
543 }
544 buf.push(c);
545 } else if c == '"' {
546 in_str = true;
547 }
548 }
549
550 if in_str {
551 return Err("Unterminated string in array".into());
552 }
553 Ok(items)
554}
555
556#[derive(Debug, PartialEq, Eq)]
561pub enum ResolveError {
562 UnknownProfile(String),
564 Cycle(Vec<String>),
566 MissingFile(PathBuf, String), }
569
570#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
572#[serde(rename_all = "lowercase")]
573pub enum TreeNodeType {
574 Profile,
576 Fragment,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct TreeNode {
583 #[serde(rename = "type")]
585 pub node_type: TreeNodeType,
586 pub name: String,
588 #[serde(skip_serializing_if = "Vec::is_empty")]
590 pub children: Vec<TreeNode>,
591}
592
593#[derive(Debug, Serialize, Deserialize)]
595pub struct TreeOutput {
596 pub trees: Vec<TreeNode>,
598}
599
600#[allow(clippy::implicit_hasher)]
624pub fn resolve_profile(
625 name: &str,
626 cfg: &Config,
627 lib: &Path,
628 seen_files: &mut HashSet<PathBuf>,
629 stack: &mut Vec<String>,
630 out: &mut Vec<PathBuf>,
631) -> Result<(), ResolveError> {
632 if stack.contains(&name.to_string()) {
633 let mut cycle = stack.clone();
634 cycle.push(name.to_string());
635 return Err(ResolveError::Cycle(cycle));
636 }
637 let deps = cfg
638 .profiles
639 .get(name)
640 .ok_or_else(|| ResolveError::UnknownProfile(name.to_string()))?;
641 stack.push(name.to_string());
642 for dep in deps {
643 if std::path::Path::new(dep)
644 .extension()
645 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
646 {
647 let path = lib.join(dep);
648 if !path.exists() {
649 return Err(ResolveError::MissingFile(path, name.to_string()));
650 }
651 if seen_files.insert(path.clone()) {
652 out.push(path);
653 }
654 } else {
655 resolve_profile(dep, cfg, lib, seen_files, stack, out)?;
656 }
657 }
658 stack.pop();
659 Ok(())
660}
661
662#[derive(Debug, Serialize)]
664struct ListOutput {
665 profiles: Vec<ProfileInfo>,
666 fragments: Vec<String>,
667}
668
669#[derive(Debug, Serialize)]
671struct ProfileInfo {
672 name: String,
673 dependencies: Vec<String>,
674}
675
676pub fn list_profiles(
694 cfg: &Config,
695 lib: &Path,
696 json: bool,
697 mut w: impl Write,
698) -> Result<(), String> {
699 if json {
700 let mut fragments = Vec::new();
702 if lib.exists() {
703 collect_fragments(lib, lib, &mut fragments)?;
704 }
705 fragments.sort();
706
707 let mut profiles: Vec<ProfileInfo> = cfg
709 .profiles
710 .iter()
711 .map(|(name, deps)| ProfileInfo {
712 name: name.clone(),
713 dependencies: deps.clone(),
714 })
715 .collect();
716 profiles.sort_by(|a, b| a.name.cmp(&b.name));
717
718 let output = ListOutput {
719 profiles,
720 fragments,
721 };
722 let json_output = serde_json::to_string_pretty(&output)
723 .map_err(|e| format!("JSON serialization error: {e}"))?;
724 writeln!(&mut w, "{json_output}").map_err(|e| format!("Write error: {e}"))?;
725 } else {
726 let mut names: Vec<_> = cfg.profiles.keys().cloned().collect();
727 names.sort();
728 for n in names {
729 writeln!(&mut w, "{n}").map_err(|e| format!("Write error: {e}"))?;
730 }
731 }
732 Ok(())
733}
734
735fn collect_fragments(root: &Path, dir: &Path, fragments: &mut Vec<String>) -> Result<(), String> {
737 let entries = fs::read_dir(dir)
738 .map_err(|e| format!("Failed to read directory {}: {}", dir.display(), e))?;
739
740 for entry in entries {
741 let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?;
742 let path = entry.path();
743
744 if path.is_dir() {
745 collect_fragments(root, &path, fragments)?;
746 } else if path
747 .extension()
748 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
749 {
750 if let Ok(rel_path) = path.strip_prefix(root) {
751 fragments.push(rel_path.display().to_string());
752 }
753 }
754 }
755
756 Ok(())
757}
758
759pub fn validate(cfg: &Config, lib: &Path) -> Result<(), String> {
780 let mut errors: Vec<String> = Vec::new();
781
782 for (profile, deps) in &cfg.profiles {
783 for dep in deps {
784 if std::path::Path::new(dep)
785 .extension()
786 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
787 {
788 let path = lib.join(dep);
789 if !path.exists() {
790 errors.push(format!(
791 "Missing file: {} (referenced by [{}])",
792 path.display(),
793 profile
794 ));
795 }
796 } else if !cfg.profiles.contains_key(dep) {
797 errors.push(format!(
798 "Unknown profile: {dep} (referenced by [{profile}])"
799 ));
800 }
801 }
802 }
803
804 for name in cfg.profiles.keys() {
805 let mut seen_files = HashSet::new();
806 let mut stack = Vec::new();
807 let mut out = Vec::new();
808 if let Err(ResolveError::Cycle(cycle)) =
809 resolve_profile(name, cfg, lib, &mut seen_files, &mut stack, &mut out)
810 {
811 let chain = cycle.join(" -> ");
812 errors.push(format!("Cycle detected: {chain}"));
813 }
814 }
815
816 if errors.is_empty() {
817 Ok(())
818 } else {
819 Err(errors.join("\n"))
820 }
821}
822
823fn build_tree_node(name: &str, cfg: &Config) -> TreeNode {
825 if std::path::Path::new(name)
827 .extension()
828 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
829 {
830 return TreeNode {
831 node_type: TreeNodeType::Fragment,
832 name: name.to_string(),
833 children: Vec::new(),
834 };
835 }
836
837 let children = cfg
839 .profiles
840 .get(name)
841 .map(|deps| deps.iter().map(|dep| build_tree_node(dep, cfg)).collect())
842 .unwrap_or_default();
843
844 TreeNode {
845 node_type: TreeNodeType::Profile,
846 name: name.to_string(),
847 children,
848 }
849}
850
851fn find_root_profiles(cfg: &Config) -> Vec<String> {
853 let mut referenced = HashSet::new();
854
855 for deps in cfg.profiles.values() {
857 for dep in deps {
858 if !std::path::Path::new(dep)
860 .extension()
861 .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
862 {
863 referenced.insert(dep.clone());
864 }
865 }
866 }
867
868 let mut roots: Vec<String> = cfg
870 .profiles
871 .keys()
872 .filter(|profile| !referenced.contains(*profile))
873 .cloned()
874 .collect();
875
876 roots.sort();
877 roots
878}
879
880fn build_trees(cfg: &Config) -> TreeOutput {
882 let root_profiles = find_root_profiles(cfg);
883 let trees = root_profiles
884 .iter()
885 .map(|profile| build_tree_node(profile, cfg))
886 .collect();
887
888 TreeOutput { trees }
889}
890
891fn print_tree(node: &TreeNode, prefix: &str, is_last: bool, w: &mut impl Write) -> io::Result<()> {
893 let connector = if is_last { "âââ " } else { "âââ " };
895 writeln!(w, "{prefix}{connector}{}", node.name)?;
896
897 let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "â " });
899
900 for (i, child) in node.children.iter().enumerate() {
902 let is_last_child = i == node.children.len() - 1;
903 print_tree(child, &child_prefix, is_last_child, w)?;
904 }
905
906 Ok(())
907}
908
909pub fn show_tree(cfg: &Config, json: bool, mut w: impl Write) -> Result<(), String> {
911 let trees = build_trees(cfg);
912
913 if json {
914 let json_output = serde_json::to_string_pretty(&trees)
915 .map_err(|e| format!("JSON serialization error: {e}"))?;
916 writeln!(&mut w, "{json_output}").map_err(|e| format!("Write error: {e}"))?;
917 } else {
918 for (i, tree) in trees.trees.iter().enumerate() {
919 writeln!(&mut w, "{}", tree.name).map_err(|e| format!("Write error: {e}"))?;
921
922 for (j, child) in tree.children.iter().enumerate() {
924 let is_last = j == tree.children.len() - 1;
925 print_tree(child, "", is_last, &mut w).map_err(|e| format!("Write error: {e}"))?;
926 }
927
928 if i < trees.trees.len() - 1 {
930 writeln!(&mut w).map_err(|e| format!("Write error: {e}"))?;
931 }
932 }
933 }
934
935 Ok(())
936}
937
938pub fn run_tree_stdout(config_override: Option<&Path>, json: bool) -> Result<(), String> {
940 let cfg_path = resolve_config_path(config_override)?;
941 let cfg_text = read_config_with_path(&cfg_path)?;
942 let cfg = parse_config_toml(&cfg_text)?;
943 show_tree(&cfg, json, io::stdout())
944}
945
946pub fn init_scaffold() -> Result<(), String> {
966 let pb = if is_terminal() {
967 let pb = ProgressBar::new_spinner();
968 pb.set_style(
969 ProgressStyle::default_spinner()
970 .tick_chars("â â â âĄâĸâ â â ")
971 .template("{spinner:.green} {msg}")
972 .unwrap(),
973 );
974 pb.set_message("Initializing prompter...");
975 pb.enable_steady_tick(std::time::Duration::from_millis(120));
976 Some(pb)
977 } else {
978 None
979 };
980
981 let cfg_path = config_path()?;
982 let cfg_dir = cfg_path
983 .parent()
984 .ok_or_else(|| "Invalid config path".to_string())?;
985
986 if let Some(ref pb) = pb {
987 pb.set_message("Creating config directory...");
988 }
989 fs::create_dir_all(cfg_dir)
990 .map_err(|e| format!("Failed to create {}: {}", cfg_dir.display(), e))?;
991
992 let lib = library_dir()?;
993 if let Some(ref pb) = pb {
994 pb.set_message("Creating library directory...");
995 }
996 fs::create_dir_all(&lib).map_err(|e| format!("Failed to create {}: {}", lib.display(), e))?;
997
998 if !cfg_path.exists() {
999 if let Some(ref pb) = pb {
1000 pb.set_message("Writing default config...");
1001 }
1002 let default_cfg = r#"# Prompter configuration
1003# Profiles map to sets of markdown files and/or other profiles.
1004# Files are relative to $HOME/.local/prompter/library
1005
1006[python.api]
1007depends_on = ["a/b/c.md", "f/g/h.md"]
1008
1009[general.testing]
1010depends_on = ["python.api", "a/b/d.md"]
1011"#;
1012 fs::write(&cfg_path, default_cfg)
1013 .map_err(|e| format!("Failed to write {}: {}", cfg_path.display(), e))?;
1014 }
1015
1016 let paths_and_contents: Vec<(PathBuf, &str)> = vec![
1017 (
1018 lib.join("a/b/c.md"),
1019 "# a/b/c.md\nExample snippet for python.api.\n",
1020 ),
1021 (lib.join("a/b.md"), "# a/b.md\nFolder-level notes.\n"),
1022 (
1023 lib.join("a/b/d.md"),
1024 "# a/b/d.md\nGeneral testing snippet.\n",
1025 ),
1026 (lib.join("f/g/h.md"), "# f/g/h.md\nShared helper snippet.\n"),
1027 ];
1028
1029 for (path, contents) in paths_and_contents {
1030 if let Some(ref pb) = pb {
1031 pb.set_message(format!(
1032 "Creating {}",
1033 path.file_name().unwrap_or_default().to_string_lossy()
1034 ));
1035 }
1036 if let Some(parent) = path.parent() {
1037 fs::create_dir_all(parent)
1038 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
1039 }
1040 if !path.exists() {
1041 fs::write(&path, contents)
1042 .map_err(|e| format!("Failed to write {}: {}", path.display(), e))?;
1043 }
1044 }
1045
1046 if let Some(pb) = pb {
1047 pb.finish_with_message("Initialization complete!");
1048 std::thread::sleep(std::time::Duration::from_millis(200)); }
1050
1051 println!(
1052 "{}",
1053 success_message(&format!("Initialized config at {}", cfg_path.display()))
1054 );
1055 println!(
1056 "{}",
1057 info_message(&format!("Library root at {}", lib.display()))
1058 );
1059 Ok(())
1060}
1061
1062pub fn run_list_stdout(config_override: Option<&Path>, json: bool) -> Result<(), String> {
1080 let cfg_path = resolve_config_path(config_override)?;
1081 let cfg_text = read_config_with_path(&cfg_path)?;
1082 let cfg = parse_config_toml(&cfg_text)?;
1083 let lib = library_path_for_config_override(config_override, &cfg_path)?;
1084 list_profiles(&cfg, &lib, json, io::stdout())
1085}
1086
1087#[derive(Debug, Serialize)]
1089struct ValidateOutput {
1090 valid: bool,
1091}
1092
1093pub fn run_validate_stdout(config_override: Option<&Path>, json: bool) -> Result<(), String> {
1111 let cfg_path = resolve_config_path(config_override)?;
1112 let cfg_text = read_config_with_path(&cfg_path)?;
1113 let cfg = parse_config_toml(&cfg_text)?;
1114 let lib = library_path_for_config_override(config_override, &cfg_path)?;
1115 validate(&cfg, &lib)?;
1116
1117 if json {
1118 let output = ValidateOutput { valid: true };
1119 let json_output = serde_json::to_string_pretty(&output)
1120 .map_err(|e| format!("JSON serialization error: {e}"))?;
1121 println!("{json_output}");
1122 }
1123
1124 Ok(())
1125}
1126
1127#[derive(Debug, Serialize)]
1129struct FragmentOutput {
1130 path: String,
1131 content: String,
1132}
1133
1134#[derive(Debug, Serialize)]
1136struct RenderOutput {
1137 profile: String,
1138 pre_prompt: String,
1139 system_info: String,
1140 fragments: Vec<FragmentOutput>,
1141}
1142
1143pub fn render_to_writer(
1171 cfg: &Config,
1172 lib: &Path,
1173 mut w: impl Write,
1174 profiles: &[String],
1175 separator: Option<&str>,
1176 pre_prompt: Option<&str>,
1177 post_prompt: Option<&str>,
1178 json: bool,
1179) -> Result<(), String> {
1180 let mut seen_files = HashSet::new();
1181 let mut files = Vec::new();
1182
1183 for profile in profiles {
1185 let mut stack = Vec::new();
1186 resolve_profile(profile, cfg, lib, &mut seen_files, &mut stack, &mut files).map_err(
1187 |e| match e {
1188 ResolveError::UnknownProfile(p) => format!("Unknown profile: {p}"),
1189 ResolveError::Cycle(c) => format!("Cycle detected: {}", c.join(" -> ")),
1190 ResolveError::MissingFile(path, prof) => format!(
1191 "Missing file: {} (referenced by [{}])",
1192 path.display(),
1193 prof
1194 ),
1195 },
1196 )?;
1197 }
1198
1199 if json {
1200 let default_pre = default_pre_prompt();
1202 let pre_prompt_text = pre_prompt.unwrap_or(&default_pre).to_string();
1203
1204 let date = Local::now().format("%Y-%m-%d").to_string();
1205 let os = env::consts::OS;
1206 let arch = env::consts::ARCH;
1207 let system_info = format!("Today is {date}, and you are running on a {arch}/{os} system.");
1208
1209 let mut fragments = Vec::new();
1210 for path in &files {
1211 let content = fs::read_to_string(path)
1212 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1213 let rel_path = path.strip_prefix(lib).unwrap_or(path).display().to_string();
1214 fragments.push(FragmentOutput {
1215 path: rel_path,
1216 content,
1217 });
1218 }
1219
1220 let output = RenderOutput {
1221 profile: profiles.join(", "),
1222 pre_prompt: pre_prompt_text,
1223 system_info,
1224 fragments,
1225 };
1226
1227 let json_output = serde_json::to_string_pretty(&output)
1228 .map_err(|e| format!("JSON serialization error: {e}"))?;
1229 writeln!(&mut w, "{json_output}").map_err(|e| format!("Write error: {e}"))?;
1230 } else {
1231 let default_pre = default_pre_prompt();
1234 let pre_prompt_text = pre_prompt.unwrap_or(&default_pre);
1235 w.write_all(pre_prompt_text.as_bytes())
1236 .map_err(|e| format!("Write error: {e}"))?;
1237
1238 w.write_all(b"\n")
1240 .map_err(|e| format!("Write error: {e}"))?;
1241 let prefix = format_system_prefix();
1242 w.write_all(prefix.as_bytes())
1243 .map_err(|e| format!("Write error: {e}"))?;
1244
1245 let sep = separator.unwrap_or("");
1246 for path in files {
1247 w.write_all(b"\n")
1249 .map_err(|e| format!("Write error: {e}"))?;
1250
1251 match fs::read(&path) {
1252 Ok(bytes) => w
1253 .write_all(&bytes)
1254 .map_err(|e| format!("Write error: {e}"))?,
1255 Err(e) => return Err(format!("Failed to read {}: {}", path.display(), e)),
1256 }
1257
1258 if !sep.is_empty() {
1260 w.write_all(sep.as_bytes())
1261 .map_err(|e| format!("Write error: {e}"))?;
1262 }
1263 }
1264
1265 let default_post = default_post_prompt();
1267 let post_prompt_text = post_prompt
1268 .or(cfg.post_prompt.as_deref())
1269 .unwrap_or(&default_post);
1270
1271 w.write_all(b"\n\n")
1273 .map_err(|e| format!("Write error: {e}"))?;
1274 w.write_all(post_prompt_text.as_bytes())
1275 .map_err(|e| format!("Write error: {e}"))?;
1276 }
1277
1278 Ok(())
1279}
1280
1281pub fn run_render_stdout(
1305 profiles: &[String],
1306 separator: Option<&str>,
1307 pre_prompt: Option<&str>,
1308 post_prompt: Option<&str>,
1309 config_override: Option<&Path>,
1310 json: bool,
1311) -> Result<(), String> {
1312 let cfg_path = resolve_config_path(config_override)?;
1313 let cfg_text = read_config_with_path(&cfg_path)?;
1314 let cfg = parse_config_toml(&cfg_text)?;
1315 let lib = library_path_for_config_override(config_override, &cfg_path)?;
1316 let stdout = io::stdout();
1317 let handle = stdout.lock();
1318 render_to_writer(
1319 &cfg,
1320 &lib,
1321 handle,
1322 profiles,
1323 separator,
1324 pre_prompt,
1325 post_prompt,
1326 json,
1327 )
1328}
1329
1330pub fn render_to_vec(
1347 profiles: &[String],
1348 config_override: Option<&Path>,
1349) -> Result<Vec<u8>, String> {
1350 let cfg_path = resolve_config_path(config_override)?;
1351 let cfg_text = read_config_with_path(&cfg_path)?;
1352 let cfg = parse_config_toml(&cfg_text)?;
1353 let lib = library_path_for_config_override(config_override, &cfg_path)?;
1354 let mut buf = Vec::new();
1355 render_to_writer(&cfg, &lib, &mut buf, profiles, None, None, None, false)?;
1356 Ok(buf)
1357}
1358
1359pub fn available_profiles(config_override: Option<&Path>) -> Result<Vec<String>, String> {
1374 let cfg_path = resolve_config_path(config_override)?;
1375 let cfg_text = read_config_with_path(&cfg_path)?;
1376 let cfg = parse_config_toml(&cfg_text)?;
1377 let mut names: Vec<String> = cfg.profiles.keys().cloned().collect();
1378 names.sort();
1379 Ok(names)
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384 use super::*;
1385
1386 fn mk_tmp(prefix: &str) -> PathBuf {
1387 let mut p = env::temp_dir();
1388 let unique = format!(
1389 "{}_{}_{}",
1390 prefix,
1391 std::process::id(),
1392 std::time::SystemTime::now()
1393 .duration_since(std::time::UNIX_EPOCH)
1394 .unwrap()
1395 .as_nanos()
1396 );
1397 p.push(unique);
1398 p
1399 }
1400
1401 #[test]
1402 fn test_unescape() {
1403 assert_eq!(unescape("a\\nb\\t\\\"\\\\c"), "a\nb\t\"\\c");
1404 assert_eq!(unescape("line1\\rline2"), "line1\rline2");
1405 assert_eq!(unescape("noesc"), "noesc");
1406 }
1407
1408 #[test]
1409 fn test_strip_comments_and_brackets_detection() {
1410 let s = r"ab#cd";
1411 assert_eq!(strip_comments(s), "ab");
1412 let s = r#""ab#cd" # trailing"#;
1413 assert_eq!(strip_comments(s), "\"ab#cd\" ");
1414 assert!(contains_closing_bracket_outside_quotes("[\"not]here\"]]"));
1415 assert!(!contains_closing_bracket_outside_quotes("[\"no]close\""));
1416 }
1417
1418 #[test]
1419 fn test_parse_array_items_escape_and_error() {
1420 let s = r#"["a\"b", "c"]"#;
1421 let items = parse_array_items(s).unwrap();
1422 assert_eq!(items, vec!["a\"b", "c"]);
1423 let err = parse_array_items("[\"unterminated").unwrap_err();
1424 assert!(err.contains("Unterminated"));
1425 }
1426
1427 #[test]
1428 fn test_parse_config_errors() {
1429 let err = parse_config_toml("[]\n").unwrap_err();
1430 assert!(err.contains("Empty section name"));
1431 let err = parse_config_toml("[p]\ndepends_on = \"x\"\n").unwrap_err();
1432 assert!(err.contains("must be an array"));
1433 let err = parse_config_toml("depends_on = [\"a.md\"]\n").unwrap_err();
1434 assert!(err.contains("outside of a profile section"));
1435 }
1436
1437 #[test]
1438 fn test_validate_success_and_unknowns() {
1439 let cfg = Config {
1440 profiles: HashMap::from([
1441 ("p1".into(), vec!["a.md".into()]),
1442 ("p2".into(), vec!["p1".into(), "b.md".into()]),
1443 ]),
1444 post_prompt: None,
1445 };
1446 let lib = mk_tmp("prompter_validate_ok");
1447 fs::create_dir_all(&lib).unwrap();
1448 fs::write(lib.join("a.md"), b"A").unwrap();
1449 fs::write(lib.join("b.md"), b"B").unwrap();
1450 assert!(validate(&cfg, &lib).is_ok());
1451 let cfg2 = Config {
1452 profiles: HashMap::from([("root".into(), vec!["nope".into()])]),
1453 post_prompt: None,
1454 };
1455 let err = validate(&cfg2, &lib).unwrap_err();
1456 assert!(err.contains("Unknown profile"));
1457 }
1458
1459 #[test]
1460 fn test_resolve_errors_and_dedup() {
1461 let cfg = Config {
1462 profiles: HashMap::from([("root".into(), vec!["missing.md".into()])]),
1463 post_prompt: None,
1464 };
1465 let lib = mk_tmp("prompter_resolve_errs");
1466 fs::create_dir_all(&lib).unwrap();
1467 let mut seen = HashSet::new();
1468 let mut stack = Vec::new();
1469 let mut out = Vec::new();
1470 let err = resolve_profile("root", &cfg, &lib, &mut seen, &mut stack, &mut out).unwrap_err();
1471 match err {
1472 ResolveError::MissingFile(_, p) => assert_eq!(p, "root"),
1473 _ => panic!("expected missing file"),
1474 }
1475
1476 let cfg2 = Config {
1477 profiles: HashMap::from([
1478 ("A".into(), vec!["a/b.md".into()]),
1479 ("B".into(), vec!["A".into(), "a/b.md".into()]),
1480 ]),
1481 post_prompt: None,
1482 };
1483 fs::create_dir_all(lib.join("a")).unwrap();
1484 fs::write(lib.join("a/b.md"), b"X").unwrap();
1485 let mut seen = HashSet::new();
1486 let mut stack = Vec::new();
1487 let mut out = Vec::new();
1488 resolve_profile("B", &cfg2, &lib, &mut seen, &mut stack, &mut out).unwrap();
1489 assert_eq!(out.len(), 1);
1490 }
1491
1492 #[test]
1493 fn test_parse_args_errors() {
1494 let args = vec!["prompter".into(), "--bogus".into()];
1496 let err = parse_args_from(args).unwrap_err();
1497 assert!(err.contains("unexpected argument"));
1498 let args = vec!["prompter".into()];
1500 let err = parse_args_from(args).unwrap_err();
1501 assert!(err.contains("Usage:") || err.contains("COMMAND"));
1502 }
1503
1504 #[test]
1505 fn test_list_profiles_order() {
1506 let cfg = Config {
1507 profiles: HashMap::from([("b".into(), vec![]), ("a".into(), vec![])]),
1508 post_prompt: None,
1509 };
1510 let lib = mk_tmp("prompter_list_order");
1511 fs::create_dir_all(&lib).unwrap();
1512 let mut out = Vec::new();
1513 super::list_profiles(&cfg, &lib, false, &mut out).unwrap();
1514 assert_eq!(String::from_utf8(out).unwrap(), "a\nb\n");
1515 }
1516
1517 #[test]
1518 fn test_validate_cycle_detected() {
1519 let cfg = Config {
1520 profiles: HashMap::from([
1521 ("A".into(), vec!["B".into()]),
1522 ("B".into(), vec!["A".into()]),
1523 ]),
1524 post_prompt: None,
1525 };
1526 let lib = mk_tmp("prompter_cycle");
1527 fs::create_dir_all(&lib).unwrap();
1528 let err = validate(&cfg, &lib).unwrap_err();
1529 assert!(err.contains("Cycle detected"));
1530 }
1531
1532 #[test]
1533 fn test_parse_config_multiline_long() {
1534 let cfg = r#"
1535[profile.x]
1536depends_on = [
1537 "a/b.md",
1538 "c/d.md",
1539 "e/f.md",
1540]
1541"#;
1542 let parsed = parse_config_toml(cfg).unwrap();
1543 assert_eq!(parsed.profiles.get("profile.x").unwrap().len(), 3);
1544 }
1545
1546 #[test]
1547 fn test_render_to_writer_basic() {
1548 let lib = mk_tmp("prompter_render_to_writer");
1550 fs::create_dir_all(lib.join("a")).unwrap();
1551 fs::create_dir_all(lib.join("f")).unwrap();
1552 fs::write(lib.join("a/x.md"), b"AX\n").unwrap();
1553 fs::write(lib.join("f/y.md"), b"FY\n").unwrap();
1554 let cfg = Config {
1556 profiles: HashMap::from([
1557 ("child".into(), vec!["a/x.md".into()]),
1558 (
1559 "root".into(),
1560 vec!["child".into(), "f/y.md".into(), "a/x.md".into()],
1561 ),
1562 ]),
1563 post_prompt: None,
1564 };
1565 let mut out = Vec::new();
1566 super::render_to_writer(
1567 &cfg,
1568 &lib,
1569 &mut out,
1570 &["root".to_string()],
1571 Some("\n--\n"),
1572 None,
1573 None,
1574 false,
1575 )
1576 .unwrap();
1577
1578 let output_str = String::from_utf8(out).unwrap();
1579 assert!(output_str.starts_with("You are an LLM coding agent."));
1581 assert!(output_str.contains("Today is "));
1583 assert!(output_str.contains(", and you are running on a "));
1584 assert!(output_str.contains(" system.\n\n"));
1585 assert!(output_str.contains("AX\n"));
1587 assert!(output_str.contains("\n--\n"));
1588 assert!(output_str.contains("FY\n"));
1589 assert!(output_str.ends_with(
1591 "Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist."
1592 ));
1593 }
1594
1595 #[test]
1596 fn test_render_to_writer_custom_pre_prompt() {
1597 let lib = mk_tmp("prompter_render_custom_pre");
1599 fs::create_dir_all(lib.join("a")).unwrap();
1600 fs::write(lib.join("a/x.md"), b"Content\n").unwrap();
1601 let cfg = Config {
1603 profiles: HashMap::from([("test".into(), vec!["a/x.md".into()])]),
1604 post_prompt: None,
1605 };
1606 let mut out = Vec::new();
1607 super::render_to_writer(
1608 &cfg,
1609 &lib,
1610 &mut out,
1611 &["test".to_string()],
1612 None,
1613 Some("Custom pre-prompt\n\n"),
1614 None,
1615 false,
1616 )
1617 .unwrap();
1618
1619 let output_str = String::from_utf8(out).unwrap();
1620 assert!(output_str.starts_with("Custom pre-prompt\n\n"));
1622 assert!(output_str.contains("Today is "));
1624 assert!(output_str.contains("Content\n"));
1626 assert!(output_str.ends_with(
1628 "Now, read the @AGENTS.md and @CLAUDE.md files in this directory, if they exist."
1629 ));
1630 }
1631
1632 #[test]
1633 fn test_render_to_writer_custom_post_prompt() {
1634 let lib = mk_tmp("prompter_render_custom_post");
1636 fs::create_dir_all(lib.join("a")).unwrap();
1637 fs::write(lib.join("a/x.md"), b"Content\n").unwrap();
1638 let cfg = Config {
1640 profiles: HashMap::from([("test".into(), vec!["a/x.md".into()])]),
1641 post_prompt: Some("Custom config post-prompt".to_string()),
1642 };
1643 let mut out = Vec::new();
1644 super::render_to_writer(
1645 &cfg,
1646 &lib,
1647 &mut out,
1648 &["test".to_string()],
1649 None,
1650 None,
1651 None,
1652 false,
1653 )
1654 .unwrap();
1655
1656 let output_str = String::from_utf8(out).unwrap();
1657 assert!(output_str.ends_with("Custom config post-prompt"));
1659
1660 let mut out2 = Vec::new();
1662 super::render_to_writer(
1663 &cfg,
1664 &lib,
1665 &mut out2,
1666 &["test".to_string()],
1667 None,
1668 None,
1669 Some("CLI post-prompt"),
1670 false,
1671 )
1672 .unwrap();
1673
1674 let output_str2 = String::from_utf8(out2).unwrap();
1675 assert!(output_str2.ends_with("CLI post-prompt"));
1677 }
1678
1679 #[test]
1680 fn test_render_multiple_profiles_with_deduplication() {
1681 let lib = mk_tmp("prompter_multi_profile_dedup");
1683 fs::create_dir_all(lib.join("shared")).unwrap();
1684 fs::create_dir_all(lib.join("a")).unwrap();
1685 fs::create_dir_all(lib.join("b")).unwrap();
1686
1687 fs::write(lib.join("shared/common.md"), b"COMMON\n").unwrap();
1688 fs::write(lib.join("a/specific.md"), b"A_SPECIFIC\n").unwrap();
1689 fs::write(lib.join("b/specific.md"), b"B_SPECIFIC\n").unwrap();
1690
1691 let cfg = Config {
1693 profiles: HashMap::from([
1694 (
1695 "profile_a".into(),
1696 vec!["shared/common.md".into(), "a/specific.md".into()],
1697 ),
1698 (
1699 "profile_b".into(),
1700 vec!["shared/common.md".into(), "b/specific.md".into()],
1701 ),
1702 ]),
1703 post_prompt: None,
1704 };
1705
1706 let mut out = Vec::new();
1708 super::render_to_writer(
1709 &cfg,
1710 &lib,
1711 &mut out,
1712 &["profile_a".to_string(), "profile_b".to_string()],
1713 Some("\n---\n"),
1714 None,
1715 None,
1716 false,
1717 )
1718 .unwrap();
1719
1720 let output_str = String::from_utf8(out).unwrap();
1721
1722 let common_count = output_str.matches("COMMON").count();
1724 assert_eq!(
1725 common_count, 1,
1726 "Common file should appear exactly once, found {common_count}"
1727 );
1728
1729 assert!(output_str.contains("A_SPECIFIC"));
1731 assert!(output_str.contains("B_SPECIFIC"));
1732
1733 let common_pos = output_str.find("COMMON").unwrap();
1735 let a_pos = output_str.find("A_SPECIFIC").unwrap();
1736 let b_pos = output_str.find("B_SPECIFIC").unwrap();
1737
1738 assert!(
1739 common_pos < a_pos,
1740 "Common file should come before A_SPECIFIC"
1741 );
1742 assert!(a_pos < b_pos, "A_SPECIFIC should come before B_SPECIFIC");
1743 }
1744
1745 #[test]
1746 fn test_parse_config_with_post_prompt() {
1747 let cfg = r#"
1748post_prompt = "Custom post prompt from config"
1749
1750[profile]
1751depends_on = ["file.md"]
1752"#;
1753 let parsed = parse_config_toml(cfg).unwrap();
1754 assert_eq!(
1755 parsed.post_prompt,
1756 Some("Custom post prompt from config".to_string())
1757 );
1758 assert_eq!(parsed.profiles.get("profile").unwrap().len(), 1);
1759 }
1760
1761 #[test]
1762 fn test_array_items_escaped_backslash() {
1763 let s = r#"["a\\"]"#; let items = parse_array_items(s).unwrap();
1765 assert_eq!(items, vec!["a\\"]);
1766 }
1767
1768 #[test]
1769 #[allow(clippy::too_many_lines)]
1770 fn test_parse_args_from() {
1771 let args = vec![
1772 "prompter".into(),
1773 "run".into(),
1774 "--separator".into(),
1775 "\\n--\\n".into(),
1776 "profile".into(),
1777 ];
1778 match parse_args_from(args).unwrap() {
1779 AppMode::Run {
1780 profiles,
1781 separator,
1782 pre_prompt,
1783 post_prompt,
1784 config,
1785 json,
1786 } => {
1787 assert_eq!(profiles, vec!["profile".to_string()]);
1788 assert_eq!(separator, Some("\n--\n".into()));
1789 assert_eq!(pre_prompt, None);
1790 assert_eq!(post_prompt, None);
1791 assert!(config.is_none());
1792 assert!(!json);
1793 }
1794 _ => panic!("expected run"),
1795 }
1796
1797 let args = vec![
1798 "prompter".into(),
1799 "run".into(),
1800 "--pre-prompt".into(),
1801 "Custom pre-prompt".into(),
1802 "profile".into(),
1803 ];
1804 match parse_args_from(args).unwrap() {
1805 AppMode::Run {
1806 profiles,
1807 separator,
1808 pre_prompt,
1809 post_prompt,
1810 config,
1811 json,
1812 } => {
1813 assert_eq!(profiles, vec!["profile".to_string()]);
1814 assert_eq!(separator, None);
1815 assert_eq!(pre_prompt, Some("Custom pre-prompt".into()));
1816 assert_eq!(post_prompt, None);
1817 assert!(config.is_none());
1818 assert!(!json);
1819 }
1820 _ => panic!("expected run"),
1821 }
1822
1823 let args = vec![
1825 "prompter".into(),
1826 "run".into(),
1827 "profile1".into(),
1828 "profile2".into(),
1829 "profile3.nested".into(),
1830 ];
1831 match parse_args_from(args).unwrap() {
1832 AppMode::Run {
1833 profiles,
1834 separator,
1835 pre_prompt,
1836 post_prompt,
1837 config,
1838 json,
1839 } => {
1840 assert_eq!(
1841 profiles,
1842 vec![
1843 "profile1".to_string(),
1844 "profile2".to_string(),
1845 "profile3.nested".to_string()
1846 ]
1847 );
1848 assert_eq!(separator, None);
1849 assert_eq!(pre_prompt, None);
1850 assert_eq!(post_prompt, None);
1851 assert!(config.is_none());
1852 assert!(!json);
1853 }
1854 _ => panic!("expected run"),
1855 }
1856
1857 let args = vec!["prompter".into(), "list".into()];
1858 assert!(matches!(
1859 parse_args_from(args).unwrap(),
1860 AppMode::List {
1861 config: None,
1862 json: false
1863 }
1864 ));
1865 let args = vec!["prompter".into(), "validate".into()];
1866 assert!(matches!(
1867 parse_args_from(args).unwrap(),
1868 AppMode::Validate {
1869 config: None,
1870 json: false
1871 }
1872 ));
1873 let args = vec!["prompter".into(), "init".into()];
1874 assert!(matches!(parse_args_from(args).unwrap(), AppMode::Init));
1875 let args = vec!["prompter".into(), "version".into()];
1876 assert!(matches!(
1877 parse_args_from(args).unwrap(),
1878 AppMode::Version { json: false }
1879 ));
1880
1881 let args = vec![
1882 "prompter".into(),
1883 "--config".into(),
1884 "custom/config.toml".into(),
1885 "list".into(),
1886 ];
1887 match parse_args_from(args).unwrap() {
1888 AppMode::List { config, json } => {
1889 assert_eq!(config, Some(PathBuf::from("custom/config.toml")));
1890 assert!(!json);
1891 }
1892 other => panic!("unexpected mode: {other:?}"),
1893 }
1894
1895 let args = vec![
1896 "prompter".into(),
1897 "run".into(),
1898 "--config".into(),
1899 "custom/config.toml".into(),
1900 "profile".into(),
1901 ];
1902 match parse_args_from(args).unwrap() {
1903 AppMode::Run { config, json, .. } => {
1904 assert_eq!(config, Some(PathBuf::from("custom/config.toml")));
1905 assert!(!json);
1906 }
1907 other => panic!("unexpected mode: {other:?}"),
1908 }
1909 }
1910
1911 struct FailAfterN {
1912 writes_done: usize,
1913 fail_on: usize,
1914 }
1915
1916 impl Write for FailAfterN {
1917 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
1918 self.writes_done += 1;
1919 if self.writes_done == self.fail_on {
1920 Err(io::Error::other("synthetic write failure"))
1921 } else {
1922 Ok(buf.len())
1923 }
1924 }
1925 fn flush(&mut self) -> io::Result<()> {
1926 Ok(())
1927 }
1928 }
1929
1930 #[test]
1931 fn test_render_to_writer_write_error_on_separator() {
1932 let lib = mk_tmp("prompter_write_err_sep");
1933 fs::create_dir_all(lib.join("a")).unwrap();
1934 fs::write(lib.join("a/x.md"), b"AX").unwrap();
1935 fs::write(lib.join("a/y.md"), b"AY").unwrap();
1936 let cfg = Config {
1937 profiles: HashMap::from([("p".into(), vec!["a/x.md".into(), "a/y.md".into()])]),
1938 post_prompt: None,
1939 };
1940 let mut w = FailAfterN {
1941 writes_done: 0,
1942 fail_on: 3,
1943 }; let err = super::render_to_writer(
1945 &cfg,
1946 &lib,
1947 &mut w,
1948 &["p".to_string()],
1949 Some("--"),
1950 None,
1951 None,
1952 false,
1953 )
1954 .unwrap_err();
1955 assert!(err.contains("Write error"), "err={err}");
1956 }
1957
1958 #[test]
1959 fn test_render_to_writer_write_error_on_file() {
1960 let lib = mk_tmp("prompter_write_err_file");
1961 fs::create_dir_all(lib.join("a")).unwrap();
1962 fs::write(lib.join("a/x.md"), b"AX").unwrap();
1963 let cfg = Config {
1964 profiles: HashMap::from([("p".into(), vec!["a/x.md".into()])]),
1965 post_prompt: None,
1966 };
1967 let mut w = FailAfterN {
1968 writes_done: 0,
1969 fail_on: 1,
1970 }; let err = super::render_to_writer(
1972 &cfg,
1973 &lib,
1974 &mut w,
1975 &["p".to_string()],
1976 Some("--"),
1977 None,
1978 None,
1979 false,
1980 )
1981 .unwrap_err();
1982 assert!(err.contains("Write error"), "err={err}");
1983 }
1984
1985 #[test]
1986 #[ignore = "Fails on CI due to HOME environment variable concurrency issues"]
1987 #[allow(unsafe_code)]
1988 fn test_run_list_and_validate_with_home_injection() {
1989 let home = mk_tmp("prompter_home_unit_ok");
1990 let cfg_dir = home.join(".config/prompter");
1991 let lib_dir = home.join(".local/prompter/library");
1992 fs::create_dir_all(&cfg_dir).unwrap();
1993 fs::create_dir_all(lib_dir.join("a")).unwrap();
1994 fs::create_dir_all(lib_dir.join("f")).unwrap();
1995 fs::write(lib_dir.join("a/x.md"), b"AX\n").unwrap();
1996 fs::write(lib_dir.join("f/y.md"), b"FY\n").unwrap();
1997 let cfg = r#"
1998[child]
1999depends_on = ["a/x.md"]
2000
2001[root]
2002depends_on = ["child", "f/y.md"]
2003"#;
2004 fs::write(cfg_dir.join("config.toml"), cfg).unwrap();
2005 let prev_home = env::var("HOME").ok();
2006 unsafe {
2007 env::set_var("HOME", &home);
2008 }
2009 assert!(super::run_validate_stdout(None, false).is_ok());
2010 assert!(super::run_list_stdout(None, false).is_ok());
2011 if let Some(prev) = prev_home {
2012 unsafe {
2013 env::set_var("HOME", prev);
2014 }
2015 } else {
2016 unsafe {
2017 env::remove_var("HOME");
2018 }
2019 }
2020 }
2021
2022 #[test]
2023 #[allow(unsafe_code)]
2024 fn test_run_validate_with_home_injection_failure() {
2025 let home = mk_tmp("prompter_home_unit_bad");
2026 let cfg_dir = home.join(".config/prompter");
2027 let lib_dir = home.join(".local/prompter/library");
2028 fs::create_dir_all(&cfg_dir).unwrap();
2029 fs::create_dir_all(&lib_dir).unwrap();
2030 let cfg = r#"
2031[root]
2032depends_on = ["missing.md", "unknown_profile"]
2033"#;
2034 fs::write(cfg_dir.join("config.toml"), cfg).unwrap();
2035 let prev_home = env::var("HOME").ok();
2036 unsafe {
2037 env::set_var("HOME", &home);
2038 }
2039 let err = super::run_validate_stdout(None, false).unwrap_err();
2040 assert!(
2041 err.contains("Missing file") && err.contains("Unknown profile"),
2042 "err={err}"
2043 );
2044 if let Some(prev) = prev_home {
2045 unsafe {
2046 env::set_var("HOME", prev);
2047 }
2048 } else {
2049 unsafe {
2050 env::remove_var("HOME");
2051 }
2052 }
2053 }
2054
2055 #[test]
2056 fn render_to_vec_returns_bytes() {
2057 let result = render_to_vec(&[], None);
2060 assert!(result.is_ok() || result.is_err());
2062 }
2063
2064 #[test]
2065 fn available_profiles_returns_sorted() {
2066 let result = available_profiles(None);
2067 if let Ok(profiles) = result {
2068 let mut sorted = profiles.clone();
2069 sorted.sort();
2070 assert_eq!(profiles, sorted);
2071 }
2072 }
2074}