Skip to main content

prompter/
lib.rs

1//! Prompter: A CLI tool for composing reusable prompt snippets.
2//!
3//! This library provides functionality for managing and rendering prompt snippets
4//! from a structured library using TOML configuration files. It supports recursive
5//! profile dependencies, file deduplication, and customizable output formatting.
6
7pub 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/// Configuration structure holding profile definitions and their dependencies.
22///
23/// Profiles map names to lists of dependencies, where dependencies can be either
24/// markdown files (ending in .md) or references to other profiles.
25#[derive(Debug)]
26pub struct Config {
27    /// Map of profile names to their dependency lists
28    pub(crate) profiles: HashMap<String, Vec<String>>,
29    /// Optional post-prompt text to append at the end of output
30    pub(crate) post_prompt: Option<String>,
31}
32
33/// Command-line interface structure for the prompter tool.
34///
35/// This structure defines the main CLI interface using clap's derive API.
36#[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    /// Subcommand to execute
43    #[command(subcommand)]
44    pub command: Commands,
45
46    /// Override configuration file path
47    #[arg(short = 'c', long, value_name = "FILE", global = true)]
48    pub config: Option<PathBuf>,
49
50    /// Output in JSON format
51    #[arg(short = 'j', long, global = true)]
52    pub json: bool,
53}
54
55/// Available subcommands for the prompter CLI.
56///
57/// Each variant represents a different operation mode of the tool.
58#[derive(Subcommand, Debug)]
59pub enum Commands {
60    /// Show version information
61    Version,
62    /// Show license information
63    License,
64    /// Initialize default config and library
65    Init,
66    /// List available profiles
67    List,
68    /// Show dependency tree for profiles
69    Tree,
70    /// Validate configuration and library references
71    Validate,
72    /// Render one or more profiles (concatenated file contents with deduplication)
73    Run {
74        /// Profile name(s) to render
75        #[arg(required = true)]
76        profiles: Vec<String>,
77        /// Separator between files
78        #[arg(short, long)]
79        separator: Option<String>,
80        /// Pre-prompt text to inject at the beginning
81        #[arg(short = 'p', long)]
82        pre_prompt: Option<String>,
83        /// Post-prompt text to inject at the end
84        #[arg(short = 'P', long)]
85        post_prompt: Option<String>,
86    },
87    /// Generate shell completion scripts
88    Completions {
89        /// Shell to generate completions for
90        #[arg(value_enum)]
91        shell: clap_complete::Shell,
92    },
93    /// Check health and configuration status
94    Doctor,
95}
96
97/// Application execution modes after parsing command-line arguments.
98///
99/// This enum represents the resolved execution mode after processing
100/// both subcommands and direct profile arguments.
101#[derive(Debug)]
102pub enum AppMode {
103    /// Render one or more profiles with optional separator and pre-prompt
104    Run {
105        /// Profile name(s) to render
106        profiles: Vec<String>,
107        /// Optional separator between concatenated files
108        separator: Option<String>,
109        /// Optional custom pre-prompt text
110        pre_prompt: Option<String>,
111        /// Optional custom post-prompt text
112        post_prompt: Option<String>,
113        /// Optional configuration file override
114        config: Option<PathBuf>,
115        /// Output in JSON format
116        json: bool,
117    },
118    /// List all available profiles using an optional config override
119    List {
120        /// Optional configuration file override
121        config: Option<PathBuf>,
122        /// Output in JSON format
123        json: bool,
124    },
125    /// Show dependency tree for profiles
126    Tree {
127        /// Optional configuration file override
128        config: Option<PathBuf>,
129        /// Output in JSON format
130        json: bool,
131    },
132    /// Validate configuration and library references with an optional config override
133    Validate {
134        /// Optional configuration file override
135        config: Option<PathBuf>,
136        /// Output in JSON format
137        json: bool,
138    },
139    /// Initialize default configuration and library
140    Init,
141    /// Show version information
142    Version {
143        /// Output in JSON format
144        json: bool,
145    },
146    /// Show license information
147    License,
148    /// Show help information
149    Help,
150    /// Generate shell completion scripts
151    Completions {
152        /// Shell to generate completions for
153        shell: clap_complete::Shell,
154    },
155    /// Check health and configuration status
156    Doctor {
157        /// Output in JSON format
158        json: bool,
159    },
160}
161
162/// Parse command-line arguments and return the resolved application mode.
163///
164/// This function takes raw command-line arguments and uses clap to parse them
165/// into a structured `AppMode` enum.
166///
167/// # Arguments
168/// * `args` - Vector of command-line arguments including program name
169///
170/// # Returns
171/// * `Ok(AppMode)` - Successfully parsed application mode
172/// * `Err(String)` - Error message if parsing fails
173///
174/// # Errors
175/// Returns an error if:
176/// - Invalid command-line syntax is provided
177/// - Required arguments are missing
178/// - Conflicting options are specified
179pub 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
192/// Resolve a parsed [`Cli`] value into the executable [`AppMode`].
193///
194/// # Errors
195///
196/// Returns an error if command-specific argument normalization fails.
197pub 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/// Unescape special characters in strings.
238///
239/// Processes escape sequences like `\n`, `\t`, `\"`, and `\\` in input strings,
240/// converting them to their literal character equivalents.
241///
242/// # Arguments
243/// * `s` - Input string that may contain escape sequences
244///
245/// # Returns
246/// String with escape sequences converted to literal characters
247///
248/// # Examples
249/// ```
250/// use prompter::unescape;
251/// assert_eq!(unescape("line1\\nline2"), "line1\nline2");
252/// ```
253#[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
374/// Parse TOML configuration into a Config structure.
375///
376/// Processes TOML input containing profile definitions and their dependencies,
377/// handling multi-line arrays and comment stripping.
378///
379/// # Arguments
380/// * `input` - TOML configuration text
381///
382/// # Returns
383/// * `Ok(Config)` - Successfully parsed configuration
384/// * `Err(String)` - Error message describing parsing failure
385///
386/// # Errors
387/// Returns an error if:
388/// - TOML syntax is invalid
389/// - Profile sections are malformed
390/// - `depends_on` arrays have invalid syntax
391pub 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/// Errors that can occur during profile resolution.
557///
558/// These errors represent various failure modes when resolving
559/// profile dependencies and validating file references.
560#[derive(Debug, PartialEq, Eq)]
561pub enum ResolveError {
562    /// Referenced profile name does not exist in configuration
563    UnknownProfile(String),
564    /// Circular dependency detected in profile references
565    Cycle(Vec<String>),
566    /// Referenced markdown file does not exist
567    MissingFile(PathBuf, String), // (path, referenced_by)
568}
569
570/// Node type in the dependency tree
571#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
572#[serde(rename_all = "lowercase")]
573pub enum TreeNodeType {
574    /// Profile node
575    Profile,
576    /// Fragment (markdown file) node
577    Fragment,
578}
579
580/// Tree node representing a profile or fragment in the dependency tree
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct TreeNode {
583    /// Type of node (profile or fragment)
584    #[serde(rename = "type")]
585    pub node_type: TreeNodeType,
586    /// Name of profile or path of fragment
587    pub name: String,
588    /// Children of this node
589    #[serde(skip_serializing_if = "Vec::is_empty")]
590    pub children: Vec<TreeNode>,
591}
592
593/// Complete tree structure for JSON output
594#[derive(Debug, Serialize, Deserialize)]
595pub struct TreeOutput {
596    /// List of root trees (top-level profiles)
597    pub trees: Vec<TreeNode>,
598}
599
600/// Recursively resolve a profile's dependencies into a list of file paths.
601///
602/// Performs depth-first traversal of profile dependencies, handling both
603/// direct file references and recursive profile dependencies. Implements
604/// cycle detection and file deduplication.
605///
606/// # Arguments
607/// * `name` - Profile name to resolve
608/// * `cfg` - Configuration containing profile definitions
609/// * `lib` - Library root directory for resolving file paths
610/// * `seen_files` - Set tracking already included files for deduplication
611/// * `stack` - Stack for cycle detection during recursion
612/// * `out` - Output vector to collect resolved file paths
613///
614/// # Returns
615/// * `Ok(())` - Profile successfully resolved
616/// * `Err(ResolveError)` - Resolution failed due to missing files, cycles, or unknown profiles
617///
618/// # Errors
619/// Returns an error if:
620/// - Profile name is not found in configuration
621/// - Circular dependency is detected
622/// - Referenced markdown file does not exist
623#[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/// JSON output structure for list command
663#[derive(Debug, Serialize)]
664struct ListOutput {
665    profiles: Vec<ProfileInfo>,
666    fragments: Vec<String>,
667}
668
669/// Profile information for JSON output
670#[derive(Debug, Serialize)]
671struct ProfileInfo {
672    name: String,
673    dependencies: Vec<String>,
674}
675
676/// List all available profiles to a writer.
677///
678/// Outputs all profile names from the configuration in alphabetical order,
679/// one per line (text mode) or as JSON (json mode).
680///
681/// # Arguments
682/// * `cfg` - Configuration containing profile definitions
683/// * `lib` - Library root directory for finding fragments
684/// * `json` - Whether to output JSON format
685/// * `w` - Writer to output profile names to
686///
687/// # Returns
688/// * `Ok(())` - All profiles listed successfully
689/// * `Err(String)` - Operation failed
690///
691/// # Errors
692/// Returns an error if writing to the output fails.
693pub fn list_profiles(
694    cfg: &Config,
695    lib: &Path,
696    json: bool,
697    mut w: impl Write,
698) -> Result<(), String> {
699    if json {
700        // Collect all fragments from library directory
701        let mut fragments = Vec::new();
702        if lib.exists() {
703            collect_fragments(lib, lib, &mut fragments)?;
704        }
705        fragments.sort();
706
707        // Build profile info
708        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
735/// Recursively collect all .md files from a directory
736fn 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
759/// Validate configuration and library file references.
760///
761/// Checks that all profile dependencies are valid, including:
762/// - Referenced profiles exist in configuration
763/// - Referenced markdown files exist in library
764/// - No circular dependencies exist
765///
766/// # Arguments
767/// * `cfg` - Configuration to validate
768/// * `lib` - Library root directory for file validation
769///
770/// # Returns
771/// * `Ok(())` - Configuration is valid
772/// * `Err(String)` - Validation errors found
773///
774/// # Errors
775/// Returns an error if:
776/// - Referenced profiles don't exist
777/// - Referenced files don't exist
778/// - Circular dependencies are detected
779pub 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
823/// Build a tree node for a profile or fragment
824fn build_tree_node(name: &str, cfg: &Config) -> TreeNode {
825    // Check if it's a fragment (ends with .md)
826    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    // It's a profile - recursively build children
838    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
851/// Find root profiles (profiles that are not referenced by any other profile)
852fn find_root_profiles(cfg: &Config) -> Vec<String> {
853    let mut referenced = HashSet::new();
854
855    // Collect all profiles that are referenced by others
856    for deps in cfg.profiles.values() {
857        for dep in deps {
858            // Only track profile references (not .md files)
859            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    // Find profiles that are never referenced
869    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
880/// Build complete tree structure for all root profiles
881fn 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
891/// Print tree structure in traditional tree format
892fn print_tree(node: &TreeNode, prefix: &str, is_last: bool, w: &mut impl Write) -> io::Result<()> {
893    // Print current node with appropriate connector
894    let connector = if is_last { "└── " } else { "├── " };
895    writeln!(w, "{prefix}{connector}{}", node.name)?;
896
897    // Prepare prefix for children
898    let child_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
899
900    // Print children
901    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
909/// Show tree structure for all profiles
910pub 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            // Print root profile name
920            writeln!(&mut w, "{}", tree.name).map_err(|e| format!("Write error: {e}"))?;
921
922            // Print children with tree structure
923            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            // Add blank line between trees (except after last one)
929            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
938/// Show tree structure to stdout
939pub 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
946/// Initialize default configuration and library structure.
947///
948/// Creates the default directory structure and configuration files
949/// for prompter, including sample profiles and library files.
950/// Only creates files that don't already exist (non-destructive).
951///
952/// # Returns
953/// * `Ok(())` - Initialization completed successfully
954/// * `Err(String)` - Initialization failed
955///
956/// # Errors
957/// Returns an error if:
958/// - Directory creation fails
959/// - File writing fails
960/// - HOME environment variable is not set
961///
962/// # Panics
963/// Panics if the progress bar template is invalid (should not happen with the
964/// hardcoded template string).
965pub 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)); // Brief pause to show completion
1049    }
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
1062/// List profiles to stdout.
1063///
1064/// Convenience function that reads configuration and lists all profiles
1065/// to standard output.
1066///
1067/// # Arguments
1068/// * `config_override` - Optional configuration file override
1069/// * `json` - Whether to output in JSON format
1070///
1071/// # Returns
1072/// * `Ok(())` - Profiles listed successfully
1073/// * `Err(String)` - Operation failed
1074///
1075/// # Errors
1076/// Returns an error if:
1077/// - Configuration file cannot be read or parsed
1078/// - Writing to stdout fails
1079pub 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/// JSON output for successful validation
1088#[derive(Debug, Serialize)]
1089struct ValidateOutput {
1090    valid: bool,
1091}
1092
1093/// Validate configuration and output results to stdout.
1094///
1095/// Convenience function that reads configuration and validates it,
1096/// outputting any errors found.
1097///
1098/// # Arguments
1099/// * `config_override` - Optional configuration file override
1100/// * `json` - Whether to output in JSON format
1101///
1102/// # Returns
1103/// * `Ok(())` - Configuration is valid
1104/// * `Err(String)` - Validation errors found
1105///
1106/// # Errors
1107/// Returns an error if:
1108/// - Configuration file cannot be read or parsed
1109/// - Validation finds missing files or circular dependencies
1110pub 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/// JSON structure for a single fragment
1128#[derive(Debug, Serialize)]
1129struct FragmentOutput {
1130    path: String,
1131    content: String,
1132}
1133
1134/// JSON output structure for render command
1135#[derive(Debug, Serialize)]
1136struct RenderOutput {
1137    profile: String,
1138    pre_prompt: String,
1139    system_info: String,
1140    fragments: Vec<FragmentOutput>,
1141}
1142
1143/// Render one or more profiles' content to a writer.
1144///
1145/// Resolves profile dependencies and writes the concatenated content
1146/// to the provided writer, including pre-prompt, system info, file
1147/// contents with optional separators, and post-prompt. When multiple
1148/// profiles are provided, files are deduplicated across all profiles
1149/// (first occurrence wins).
1150///
1151/// # Arguments
1152/// * `cfg` - Configuration containing profile definitions
1153/// * `lib` - Library root directory for file resolution
1154/// * `w` - Writer to output rendered content to
1155/// * `profiles` - Profile names to render (deduplicated in order)
1156/// * `separator` - Optional separator between files
1157/// * `pre_prompt` - Optional custom pre-prompt (defaults to LLM instructions)
1158/// * `post_prompt` - Optional custom post-prompt (defaults to @AGENTS/@CLAUDE instructions)
1159/// * `json` - Whether to output in JSON format
1160///
1161/// # Returns
1162/// * `Ok(())` - Profiles rendered successfully
1163/// * `Err(String)` - Rendering failed
1164///
1165/// # Errors
1166/// Returns an error if:
1167/// - Profile resolution fails (missing files, cycles, unknown profiles)
1168/// - Writing to output fails
1169/// - File reading fails
1170pub 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    // Resolve all profiles with shared deduplication
1184    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        // JSON output mode
1201        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        // Text output mode
1232        // Write pre-prompt (defaults if not provided)
1233        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        // Write system prefix with two newlines before
1239        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            // Two newlines before each file
1248            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            // Write separator after each file if provided
1259            if !sep.is_empty() {
1260                w.write_all(sep.as_bytes())
1261                    .map_err(|e| format!("Write error: {e}"))?;
1262            }
1263        }
1264
1265        // Write post-prompt (defaults if not provided)
1266        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        // Two newlines before post-prompt
1272        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
1281/// Render one or more profiles to stdout.
1282///
1283/// Convenience function that reads configuration and renders the specified
1284/// profiles to standard output with optional separator, pre-prompt, and post-prompt.
1285/// When multiple profiles are provided, files are deduplicated across all profiles.
1286///
1287/// # Arguments
1288/// * `profiles` - Profile names to render (deduplicated in order)
1289/// * `separator` - Optional separator between files
1290/// * `pre_prompt` - Optional custom pre-prompt text
1291/// * `post_prompt` - Optional custom post-prompt text
1292/// * `config_override` - Optional configuration file override
1293/// * `json` - Whether to output in JSON format
1294///
1295/// # Returns
1296/// * `Ok(())` - Profiles rendered successfully
1297/// * `Err(String)` - Rendering failed
1298///
1299/// # Errors
1300/// Returns an error if:
1301/// - Configuration file cannot be read or parsed
1302/// - Profile resolution fails
1303/// - Writing to stdout fails
1304pub 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
1330/// Render composed profiles to a byte vector.
1331///
1332/// Convenience wrapper around [`render_to_writer`] that handles config
1333/// resolution and returns the rendered output as bytes. Intended for
1334/// use by other crates that need prompt composition as a library.
1335///
1336/// # Arguments
1337/// * `profiles` - Profile names to compose
1338/// * `config_override` - Optional path to custom config file
1339///
1340/// # Returns
1341/// * `Ok(Vec<u8>)` - Rendered prompt content
1342/// * `Err(String)` - Error message
1343///
1344/// # Errors
1345/// Returns an error if config resolution, profile resolution, or rendering fails.
1346pub 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
1359/// List available profile names.
1360///
1361/// Returns all profile names from the configuration as a sorted vector.
1362/// Intended for use by other crates that need to validate profile names.
1363///
1364/// # Arguments
1365/// * `config_override` - Optional path to custom config file
1366///
1367/// # Returns
1368/// * `Ok(Vec<String>)` - Sorted profile names
1369/// * `Err(String)` - Error message
1370///
1371/// # Errors
1372/// Returns an error if config resolution or parsing fails.
1373pub 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        // unknown flag
1495        let args = vec!["prompter".into(), "--bogus".into()];
1496        let err = parse_args_from(args).unwrap_err();
1497        assert!(err.contains("unexpected argument"));
1498        // missing required subcommand
1499        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        // library and files
1549        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        // config with nested profile and duplicate file reference
1555        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        // Should start with default pre-prompt
1580        assert!(output_str.starts_with("You are an LLM coding agent."));
1581        // Should contain system prefix
1582        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        // Should contain the file contents with separator
1586        assert!(output_str.contains("AX\n"));
1587        assert!(output_str.contains("\n--\n"));
1588        assert!(output_str.contains("FY\n"));
1589        // Should end with default post-prompt
1590        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        // library and files
1598        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        // config
1602        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        // Should start with custom pre-prompt
1621        assert!(output_str.starts_with("Custom pre-prompt\n\n"));
1622        // Should contain system prefix
1623        assert!(output_str.contains("Today is "));
1624        // Should contain file content
1625        assert!(output_str.contains("Content\n"));
1626        // Should end with default post-prompt
1627        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        // library and files
1635        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        // config with custom post_prompt
1639        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        // Should end with config post-prompt
1658        assert!(output_str.ends_with("Custom config post-prompt"));
1659
1660        // Test CLI post-prompt overriding config
1661        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        // Should end with CLI post-prompt
1676        assert!(output_str2.ends_with("CLI post-prompt"));
1677    }
1678
1679    #[test]
1680    fn test_render_multiple_profiles_with_deduplication() {
1681        // Create library with files that will be shared across profiles
1682        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        // Create config where both profiles depend on the common file
1692        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        // Render both profiles together
1707        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        // Verify the common file appears only once
1723        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        // Verify both specific files appear
1730        assert!(output_str.contains("A_SPECIFIC"));
1731        assert!(output_str.contains("B_SPECIFIC"));
1732
1733        // Verify order: common should appear first (from profile_a), then a_specific, then b_specific
1734        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\\"]"#; // a single backslash in content
1764        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        // Test multiple profiles with run subcommand
1824        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        }; // pre-prompt ok, system prefix ok, fail on separator
1944        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        }; // fail on first write (pre-prompt)
1971        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        // This test uses the real config, so it depends on prompter being configured.
2058        // If no config exists, it should return an error, not panic.
2059        let result = render_to_vec(&[], None);
2060        // Empty profiles should succeed (produces empty or minimal output)
2061        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        // If no config, error is acceptable
2073    }
2074}