Skip to main content

prompter/
profile.rs

1//! Profile resolution, dependency trees, and listing.
2use crate::config::Config;
3use crate::config::load_bundle;
4use crate::{
5    ProfileDef, PrompterError, home_dir, parse_config_file, read_config_with_path, unescape,
6};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::collections::HashSet;
10use std::ffi::OsStr;
11use std::fs;
12use std::io;
13use std::io::Write;
14use std::path::{Component, Path, PathBuf};
15use std::str::FromStr;
16use tftio_lib::{JsonOutput, render_response};
17
18/// A model-family directory name used for fragment substitution.
19///
20/// Family names are restricted to one non-empty path component so callers
21/// cannot escape a fragment's `families/` directory.
22#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct FamilyName(String);
24
25impl FamilyName {
26    /// Parse and validate a family name.
27    ///
28    /// # Errors
29    /// Returns [`InvalidFamilyName`] when `value` is empty, absolute, or contains
30    /// path separators or traversal components.
31    pub fn new(value: impl Into<String>) -> Result<Self, InvalidFamilyName> {
32        let value = value.into();
33        let mut components = Path::new(&value).components();
34        let is_single_component = matches!(
35            components.next(),
36            Some(Component::Normal(component)) if component == OsStr::new(&value)
37        ) && components.next().is_none();
38
39        if is_single_component {
40            Ok(Self(value))
41        } else {
42            Err(InvalidFamilyName(value))
43        }
44    }
45
46    /// Return the validated directory name.
47    #[must_use]
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51}
52
53impl FromStr for FamilyName {
54    type Err = InvalidFamilyName;
55
56    fn from_str(value: &str) -> Result<Self, Self::Err> {
57        Self::new(value)
58    }
59}
60
61impl std::fmt::Display for FamilyName {
62    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        formatter.write_str(self.as_str())
64    }
65}
66
67/// Error returned when a family name is not a single safe path component.
68#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
69#[error("family name must be one non-empty path component: {0}")]
70pub struct InvalidFamilyName(String);
71
72pub(crate) fn collect_profiles(
73    prefix: &str,
74    value: &toml::Value,
75    out: &mut HashMap<String, Vec<String>>,
76) -> Result<(), PrompterError> {
77    let Some(table) = value.as_table() else {
78        return Ok(());
79    };
80
81    if let Some(deps_value) = table.get("depends_on") {
82        let arr = deps_value.as_array().ok_or_else(|| {
83            PrompterError::ConfigField(format!(
84                "`depends_on` for [{prefix}] must be an array of strings"
85            ))
86        })?;
87        let mut deps = Vec::with_capacity(arr.len());
88        for entry in arr {
89            let s = entry.as_str().ok_or_else(|| {
90                PrompterError::ConfigField(format!(
91                    "`depends_on` entries for [{prefix}] must be strings"
92                ))
93            })?;
94            deps.push(s.to_string());
95        }
96        if out.insert(prefix.to_string(), deps).is_some() {
97            return Err(PrompterError::DuplicateProfile(format!(
98                "Duplicate profile definition: [{prefix}]"
99            )));
100        }
101    }
102
103    for (sub_key, sub_val) in table {
104        if sub_key == "depends_on" {
105            continue;
106        }
107        let new_prefix = if prefix.is_empty() {
108            sub_key.clone()
109        } else {
110            format!("{prefix}.{sub_key}")
111        };
112        collect_profiles(&new_prefix, sub_val, out)?;
113    }
114    Ok(())
115}
116
117/// Expand a leading `~` or `~/` to the home directory.
118///
119/// Paths without a leading `~` are returned unchanged.
120pub(crate) fn expand_tilde(s: &str) -> Result<PathBuf, PrompterError> {
121    if let Some(rest) = s.strip_prefix("~/") {
122        Ok(home_dir()?.join(rest))
123    } else if s == "~" {
124        home_dir()
125    } else {
126        Ok(PathBuf::from(s))
127    }
128}
129
130/// Resolve an `import = [...]` entry against the importing config's directory.
131///
132/// Absolute paths are returned as-is (after `~` expansion); relative paths
133/// are joined onto `importer_dir`.
134fn resolve_import_path(importer_dir: &Path, import_str: &str) -> Result<PathBuf, PrompterError> {
135    let p = expand_tilde(import_str)?;
136    if p.is_absolute() {
137        Ok(p)
138    } else {
139        Ok(importer_dir.join(p))
140    }
141}
142
143/// Determine the library root for a loaded config file.
144///
145/// * If the config has an explicit `library = "..."` key, expand tilde and
146///   resolve it relative to the config file's directory.
147/// * Otherwise, if `default_library` is provided (only the primary config
148///   with no `-c` override gets this), use that.
149/// * Otherwise, default to `<config-dir>/library`.
150fn library_root_for(
151    config_path: &Path,
152    raw_library: Option<&str>,
153    default_library: Option<&Path>,
154) -> Result<PathBuf, PrompterError> {
155    let config_dir = config_path
156        .parent()
157        .ok_or_else(|| PrompterError::NoParentDir(config_path.to_path_buf()))?;
158
159    if let Some(lib_str) = raw_library {
160        let p = expand_tilde(lib_str)?;
161        return Ok(if p.is_absolute() {
162            p
163        } else {
164            config_dir.join(p)
165        });
166    }
167
168    if let Some(default) = default_library {
169        return Ok(default.to_path_buf());
170    }
171
172    Ok(config_dir.join("library"))
173}
174
175/// Load a primary config file plus all of its transitive imports and merge them.
176///
177/// Invariants enforced:
178/// * Profile names must be unique across the entire merged bundle (hard error).
179/// * Import cycles are detected and reported.
180/// * Only the primary file's `post_prompt` is kept; imports' are ignored.
181///
182/// `primary_default_library` is the library root to use for the primary
183/// config when it does not declare `library = "..."`. Pass
184/// `Some(~/.local/prompter/library)` when loading the default config, or
185/// `None` when the user supplied `-c FILE` (in which case the primary falls
186/// back to `<FILE-dir>/library`).
187///
188/// # Errors
189/// Returns an error on I/O failure, TOML parse failure, unresolved import
190/// paths, import cycles, or duplicate profile names.
191pub fn load_config_bundle(
192    primary_path: &Path,
193    primary_default_library: Option<&Path>,
194) -> Result<Config, PrompterError> {
195    let mut profiles: HashMap<String, ProfileDef> = HashMap::new();
196    let mut visited: HashSet<PathBuf> = HashSet::new();
197    let mut post_prompt: Option<String> = None;
198
199    load_one(
200        primary_path,
201        primary_default_library,
202        true,
203        &mut profiles,
204        &mut visited,
205        &mut post_prompt,
206    )?;
207
208    Ok(Config {
209        profiles,
210        post_prompt,
211    })
212}
213
214fn load_one(
215    path: &Path,
216    default_library: Option<&Path>,
217    is_primary: bool,
218    profiles: &mut HashMap<String, ProfileDef>,
219    visited: &mut HashSet<PathBuf>,
220    post_prompt: &mut Option<String>,
221) -> Result<(), PrompterError> {
222    let text = read_config_with_path(path)?;
223    let canonical = fs::canonicalize(path).map_err(|source| PrompterError::Io {
224        path: path.to_path_buf(),
225        source,
226    })?;
227
228    if !visited.insert(canonical.clone()) {
229        return Err(PrompterError::ImportCycle(canonical));
230    }
231
232    let raw = parse_config_file(&text)?;
233
234    let library_root = library_root_for(&canonical, raw.library.as_deref(), default_library)?;
235
236    for (name, deps) in raw.profiles {
237        if let Some(existing) = profiles.get(&name) {
238            return Err(PrompterError::DuplicateProfile(format!(
239                "Duplicate profile `{}` defined in both {} and {}",
240                name,
241                existing.library_root.display(),
242                library_root.display()
243            )));
244        }
245        profiles.insert(
246            name,
247            ProfileDef {
248                deps,
249                library_root: library_root.clone(),
250            },
251        );
252    }
253
254    if is_primary {
255        *post_prompt = raw.post_prompt.map(|s| unescape(&s));
256    }
257
258    let importer_dir = canonical
259        .parent()
260        .ok_or_else(|| PrompterError::NoParentDir(canonical.clone()))?
261        .to_path_buf();
262    for import_str in raw.imports {
263        let import_path = resolve_import_path(&importer_dir, &import_str)?;
264        // Imported configs never use the primary default library; they always
265        // resolve their library via `library = "..."` or `<their-dir>/library`.
266        load_one(&import_path, None, false, profiles, visited, post_prompt)?;
267    }
268
269    Ok(())
270}
271
272/// Errors that can occur during profile resolution.
273///
274/// These errors represent various failure modes when resolving
275/// profile dependencies and validating file references.
276#[derive(thiserror::Error, Debug, PartialEq, Eq)]
277pub enum ResolveError {
278    /// Referenced profile name does not exist in configuration
279    #[error("Unknown profile: {0}")]
280    UnknownProfile(String),
281    /// Circular dependency detected in profile references
282    #[error("Cycle detected: {}", .0.join(" -> "))]
283    Cycle(Vec<String>),
284    /// Referenced markdown file does not exist
285    #[error("Missing file: {path} (referenced by [{referenced_by}])", path = .0.display(), referenced_by = .1)]
286    MissingFile(PathBuf, String), // (path, referenced_by)
287}
288
289/// Node type in the dependency tree
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
291#[serde(rename_all = "lowercase")]
292pub enum TreeNodeType {
293    /// Profile node
294    Profile,
295    /// Fragment (markdown file) node
296    Fragment,
297}
298
299/// Tree node representing a profile or fragment in the dependency tree
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct TreeNode {
302    /// Type of node (profile or fragment)
303    #[serde(rename = "type")]
304    pub node_type: TreeNodeType,
305    /// Name of profile or path of fragment
306    pub name: String,
307    /// Children of this node
308    #[serde(skip_serializing_if = "Vec::is_empty")]
309    pub children: Vec<Self>,
310}
311
312/// Complete tree structure for JSON output
313#[derive(Debug, Serialize, Deserialize)]
314pub struct TreeOutput {
315    /// List of root trees (top-level profiles)
316    pub trees: Vec<TreeNode>,
317}
318
319/// Recursively resolve a profile's dependencies into a list of
320/// `(fragment_path, owning_library_root)` pairs.
321///
322/// Each `.md` dep is resolved against the owning profile's `library_root`,
323/// so profiles imported from different bundles still find their fragments.
324/// Profile-to-profile refs are looked up by name in the merged map.
325///
326/// Cycle detection and fragment deduplication (by absolute path) are
327/// performed across the full traversal.
328///
329/// # Errors
330/// Returns an error if:
331/// - Profile name is not found in configuration
332/// - Circular dependency is detected
333/// - Referenced markdown file does not exist
334#[allow(
335    clippy::implicit_hasher,
336    reason = "public resolver API intentionally fixes the std default HashMap hasher rather than exposing a hasher type parameter"
337)]
338pub fn resolve_profile(
339    name: &str,
340    cfg: &Config,
341    seen_files: &mut HashSet<PathBuf>,
342    stack: &mut Vec<String>,
343    out: &mut Vec<(PathBuf, PathBuf)>,
344) -> Result<(), ResolveError> {
345    resolve_profile_for_family(name, cfg, None, seen_files, stack, out)
346}
347
348pub(crate) fn resolve_profile_for_family(
349    name: &str,
350    cfg: &Config,
351    family: Option<&FamilyName>,
352    seen_files: &mut HashSet<PathBuf>,
353    stack: &mut Vec<String>,
354    out: &mut Vec<(PathBuf, PathBuf)>,
355) -> Result<(), ResolveError> {
356    if stack.contains(&name.to_string()) {
357        let mut cycle = stack.clone();
358        cycle.push(name.to_string());
359        return Err(ResolveError::Cycle(cycle));
360    }
361    let profile = cfg
362        .profiles
363        .get(name)
364        .ok_or_else(|| ResolveError::UnknownProfile(name.to_string()))?;
365    stack.push(name.to_string());
366    for dep in &profile.deps {
367        if std::path::Path::new(dep)
368            .extension()
369            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
370        {
371            let path = profile.library_root.join(dep);
372            if !path.exists() {
373                return Err(ResolveError::MissingFile(path, name.to_string()));
374            }
375            if seen_files.insert(path.clone()) {
376                let rendered_path = family.map_or_else(
377                    || path.clone(),
378                    |family| {
379                        let variant = path.parent().map_or_else(
380                            || path.clone(),
381                            |parent| {
382                                parent
383                                    .join("families")
384                                    .join(family.as_str())
385                                    .join(path.file_name().unwrap_or_default())
386                            },
387                        );
388                        if variant.is_file() {
389                            variant
390                        } else {
391                            path.clone()
392                        }
393                    },
394                );
395                out.push((rendered_path, profile.library_root.clone()));
396            }
397        } else {
398            resolve_profile_for_family(dep, cfg, family, seen_files, stack, out)?;
399        }
400    }
401    stack.pop();
402    Ok(())
403}
404
405/// JSON output structure for list command.
406#[derive(Debug, Serialize)]
407struct ListOutput {
408    profiles: Vec<ProfileInfo>,
409    libraries: Vec<LibraryOutput>,
410}
411
412/// Profile information for JSON output.
413#[derive(Debug, Serialize)]
414struct ProfileInfo {
415    name: String,
416    dependencies: Vec<String>,
417    library_root: String,
418}
419
420/// One library directory plus the fragment paths it contains.
421#[derive(Debug, Serialize)]
422struct LibraryOutput {
423    path: String,
424    fragments: Vec<String>,
425}
426
427/// List all available profiles to a writer.
428///
429/// Text mode: one profile name per line, sorted alphabetically.
430///
431/// JSON mode: emits profiles (each tagged with the library root it was loaded
432/// against) plus a `libraries` array, one entry per unique library root seen
433/// across the merged bundle.
434///
435/// # Errors
436/// Returns an error if writing to the output fails or a library directory
437/// cannot be read.
438pub fn list_profiles(
439    cfg: &Config,
440    output: JsonOutput,
441    mut w: impl Write,
442) -> Result<(), PrompterError> {
443    if output.is_json() {
444        // Gather unique library roots from all loaded profiles.
445        let mut unique_roots: Vec<PathBuf> = Vec::new();
446        for profile in cfg.profiles.values() {
447            if !unique_roots.contains(&profile.library_root) {
448                unique_roots.push(profile.library_root.clone());
449            }
450        }
451        unique_roots.sort();
452
453        let mut libraries = Vec::with_capacity(unique_roots.len());
454        for root in &unique_roots {
455            let mut fragments = Vec::new();
456            if root.exists() {
457                collect_fragments(root, root, &mut fragments)?;
458            }
459            fragments.sort();
460            libraries.push(LibraryOutput {
461                path: root.display().to_string(),
462                fragments,
463            });
464        }
465
466        let mut profiles: Vec<ProfileInfo> = cfg
467            .profiles
468            .iter()
469            .map(|(name, def)| ProfileInfo {
470                name: name.clone(),
471                dependencies: def.deps.clone(),
472                library_root: def.library_root.display().to_string(),
473            })
474            .collect();
475        profiles.sort_by(|a, b| a.name.cmp(&b.name));
476
477        let data = serde_json::to_value(ListOutput {
478            profiles,
479            libraries,
480        })?;
481        writeln!(
482            &mut w,
483            "{}",
484            render_response("list", JsonOutput::Json, data, String::new())
485        )
486        .map_err(PrompterError::Write)?;
487    } else {
488        let mut names: Vec<_> = cfg.profiles.keys().cloned().collect();
489        names.sort();
490        for n in names {
491            writeln!(&mut w, "{n}").map_err(PrompterError::Write)?;
492        }
493    }
494    Ok(())
495}
496
497/// Recursively collect all .md files from a directory
498fn collect_fragments(
499    root: &Path,
500    dir: &Path,
501    fragments: &mut Vec<String>,
502) -> Result<(), PrompterError> {
503    let entries = fs::read_dir(dir).map_err(|source| PrompterError::Io {
504        path: dir.to_path_buf(),
505        source,
506    })?;
507
508    for entry in entries {
509        let entry = entry.map_err(|source| PrompterError::Io {
510            path: dir.to_path_buf(),
511            source,
512        })?;
513        let path = entry.path();
514
515        if path.is_dir() {
516            collect_fragments(root, &path, fragments)?;
517        } else if path
518            .extension()
519            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
520            && let Ok(rel_path) = path.strip_prefix(root)
521        {
522            fragments.push(rel_path.display().to_string());
523        }
524    }
525
526    Ok(())
527}
528
529fn inspect_family_variants(
530    directory: &Path,
531    families: &mut HashSet<FamilyName>,
532    errors: &mut Vec<String>,
533) -> Result<(), PrompterError> {
534    let entries = fs::read_dir(directory).map_err(|source| PrompterError::Io {
535        path: directory.to_path_buf(),
536        source,
537    })?;
538
539    for entry in entries {
540        let entry = entry.map_err(|source| PrompterError::Io {
541            path: directory.to_path_buf(),
542            source,
543        })?;
544        let path = entry.path();
545        let file_type = entry.file_type().map_err(|source| PrompterError::Io {
546            path: path.clone(),
547            source,
548        })?;
549        if !file_type.is_dir() {
550            continue;
551        }
552
553        if entry.file_name() == OsStr::new("families") {
554            inspect_families_directory(directory, &path, families, errors)?;
555        } else {
556            inspect_family_variants(&path, families, errors)?;
557        }
558    }
559
560    Ok(())
561}
562
563fn inspect_families_directory(
564    fragment_directory: &Path,
565    families_directory: &Path,
566    families: &mut HashSet<FamilyName>,
567    errors: &mut Vec<String>,
568) -> Result<(), PrompterError> {
569    let entries = fs::read_dir(families_directory).map_err(|source| PrompterError::Io {
570        path: families_directory.to_path_buf(),
571        source,
572    })?;
573
574    for entry in entries {
575        let entry = entry.map_err(|source| PrompterError::Io {
576            path: families_directory.to_path_buf(),
577            source,
578        })?;
579        let family_directory = entry.path();
580        let file_type = entry.file_type().map_err(|source| PrompterError::Io {
581            path: family_directory.clone(),
582            source,
583        })?;
584        if !file_type.is_dir() {
585            continue;
586        }
587
588        let family_os = entry.file_name();
589        let Some(family_text) = family_os.to_str() else {
590            errors.push(format!(
591                "Invalid non-UTF-8 family directory: {}",
592                family_directory.display()
593            ));
594            continue;
595        };
596        let family = match FamilyName::new(family_text) {
597            Ok(family) => family,
598            Err(error) => {
599                errors.push(format!("{error}: {}", family_directory.display()));
600                continue;
601            }
602        };
603        families.insert(family);
604
605        let variants = fs::read_dir(&family_directory).map_err(|source| PrompterError::Io {
606            path: family_directory.clone(),
607            source,
608        })?;
609        for variant in variants {
610            let variant = variant.map_err(|source| PrompterError::Io {
611                path: family_directory.clone(),
612                source,
613            })?;
614            let variant_path = variant.path();
615            let variant_type = variant.file_type().map_err(|source| PrompterError::Io {
616                path: variant_path.clone(),
617                source,
618            })?;
619            if !variant_type.is_file()
620                || !variant_path
621                    .extension()
622                    .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
623            {
624                continue;
625            }
626
627            let neutral_path = fragment_directory.join(variant.file_name());
628            if !neutral_path.is_file() {
629                errors.push(format!(
630                    "Orphan family variant: {} does not shadow neutral fragment {}",
631                    variant_path.display(),
632                    neutral_path.display()
633                ));
634            }
635        }
636    }
637
638    Ok(())
639}
640
641/// Validate configuration and library file references.
642///
643/// Checks that all profile dependencies are valid, including:
644/// - Referenced profiles exist in configuration
645/// - Referenced markdown files exist in their owning library root
646/// - No circular dependencies exist
647///
648/// # Errors
649/// Returns an error if:
650/// - Referenced profiles don't exist
651/// - Referenced files don't exist
652/// - Circular dependencies are detected
653pub fn validate(cfg: &Config) -> Result<(), PrompterError> {
654    let mut errors: Vec<String> = Vec::new();
655
656    for (profile_name, profile) in &cfg.profiles {
657        for dep in &profile.deps {
658            if std::path::Path::new(dep)
659                .extension()
660                .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
661            {
662                let path = profile.library_root.join(dep);
663                if !path.exists() {
664                    errors.push(format!(
665                        "Missing file: {} (referenced by [{}])",
666                        path.display(),
667                        profile_name
668                    ));
669                }
670            } else if !cfg.profiles.contains_key(dep) {
671                errors.push(format!(
672                    "Unknown profile: {dep} (referenced by [{profile_name}])"
673                ));
674            }
675        }
676    }
677
678    let mut family_set = HashSet::new();
679    for library_root in cfg.library_roots() {
680        inspect_family_variants(&library_root, &mut family_set, &mut errors)?;
681    }
682    let mut families: Vec<_> = family_set.into_iter().collect();
683    families.sort();
684
685    for name in cfg.profiles.keys() {
686        for family in std::iter::once(None).chain(families.iter().map(Some)) {
687            let mut seen_files = HashSet::new();
688            let mut stack = Vec::new();
689            let mut out = Vec::new();
690            if let Err(error) =
691                resolve_profile_for_family(name, cfg, family, &mut seen_files, &mut stack, &mut out)
692            {
693                let message = error.to_string();
694                if !errors.contains(&message) {
695                    errors.push(message);
696                }
697            }
698        }
699    }
700
701    if errors.is_empty() {
702        Ok(())
703    } else {
704        Err(PrompterError::Validation(errors.join("\n")))
705    }
706}
707
708/// Build a tree node for a profile or fragment
709fn build_tree_node(name: &str, cfg: &Config) -> TreeNode {
710    // Check if it's a fragment (ends with .md)
711    if std::path::Path::new(name)
712        .extension()
713        .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
714    {
715        return TreeNode {
716            node_type: TreeNodeType::Fragment,
717            name: name.to_string(),
718            children: Vec::new(),
719        };
720    }
721
722    // It's a profile - recursively build children
723    let children = cfg
724        .profiles
725        .get(name)
726        .map(|def| {
727            def.deps
728                .iter()
729                .map(|dep| build_tree_node(dep, cfg))
730                .collect()
731        })
732        .unwrap_or_default();
733
734    TreeNode {
735        node_type: TreeNodeType::Profile,
736        name: name.to_string(),
737        children,
738    }
739}
740
741/// Find root profiles (profiles that are not referenced by any other profile)
742fn find_root_profiles(cfg: &Config) -> Vec<String> {
743    let mut referenced = HashSet::new();
744
745    // Collect all profiles that are referenced by others
746    for profile in cfg.profiles.values() {
747        for dep in &profile.deps {
748            // Only track profile references (not .md files)
749            if !std::path::Path::new(dep)
750                .extension()
751                .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
752            {
753                referenced.insert(dep.clone());
754            }
755        }
756    }
757
758    // Find profiles that are never referenced
759    let mut roots: Vec<String> = cfg
760        .profiles
761        .keys()
762        .filter(|profile| !referenced.contains(*profile))
763        .cloned()
764        .collect();
765
766    roots.sort();
767    roots
768}
769
770/// Build complete tree structure for all root profiles
771fn build_trees(cfg: &Config) -> TreeOutput {
772    let root_profiles = find_root_profiles(cfg);
773    let trees = root_profiles
774        .iter()
775        .map(|profile| build_tree_node(profile, cfg))
776        .collect();
777
778    TreeOutput { trees }
779}
780
781/// Print tree structure in traditional tree format
782fn print_tree(node: &TreeNode, prefix: &str, is_last: bool, w: &mut impl Write) -> io::Result<()> {
783    // Print current node with appropriate connector
784    let connector = if is_last { "└── " } else { "├── " };
785    writeln!(w, "{prefix}{connector}{}", node.name)?;
786
787    // Prepare prefix for children
788    let child_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
789
790    // Print children
791    for (i, child) in node.children.iter().enumerate() {
792        let is_last_child = i == node.children.len() - 1;
793        print_tree(child, &child_prefix, is_last_child, w)?;
794    }
795
796    Ok(())
797}
798
799/// Show tree structure for all profiles.
800///
801/// # Errors
802/// Returns an error if serializing the tree to JSON fails or if writing the
803/// rendered tree to the output sink fails.
804pub fn show_tree(cfg: &Config, output: JsonOutput, mut w: impl Write) -> Result<(), PrompterError> {
805    let trees = build_trees(cfg);
806
807    if output.is_json() {
808        let data = serde_json::to_value(&trees)?;
809        writeln!(
810            &mut w,
811            "{}",
812            render_response("tree", JsonOutput::Json, data, String::new())
813        )
814        .map_err(PrompterError::Write)?;
815    } else {
816        for (i, tree) in trees.trees.iter().enumerate() {
817            // Print root profile name
818            writeln!(&mut w, "{}", tree.name).map_err(PrompterError::Write)?;
819
820            // Print children with tree structure
821            for (j, child) in tree.children.iter().enumerate() {
822                let is_last = j == tree.children.len() - 1;
823                print_tree(child, "", is_last, &mut w).map_err(PrompterError::Write)?;
824            }
825
826            // Add blank line between trees (except after last one)
827            if i < trees.trees.len() - 1 {
828                writeln!(&mut w).map_err(PrompterError::Write)?;
829            }
830        }
831    }
832
833    Ok(())
834}
835
836/// Show tree structure to stdout.
837///
838/// # Errors
839/// Returns an error if the configuration bundle cannot be loaded or if writing
840/// the tree to stdout fails.
841pub fn run_tree_stdout(
842    config_override: Option<&Path>,
843    output: JsonOutput,
844) -> Result<(), PrompterError> {
845    let (_cfg_path, cfg) = load_bundle(config_override)?;
846    show_tree(&cfg, output, io::stdout())
847}
848
849#[cfg(test)]
850#[allow(clippy::wildcard_imports)]
851mod tests {
852    use super::*;
853    use std::sync::atomic::{AtomicU32, Ordering};
854
855    static COUNTER: AtomicU32 = AtomicU32::new(0);
856
857    fn mk_tmp(prefix: &str) -> PathBuf {
858        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
859        std::env::temp_dir().join(format!("{prefix}_{}_{n}", std::process::id()))
860    }
861
862    /// Build a [`Config`] from `(name, deps, library_root)` triples.
863    fn cfg_from_roots(profiles: Vec<(&str, Vec<&str>, PathBuf)>) -> Config {
864        let map = profiles
865            .into_iter()
866            .map(|(name, deps, root)| {
867                (
868                    name.to_string(),
869                    ProfileDef {
870                        deps: deps.into_iter().map(String::from).collect(),
871                        library_root: root,
872                    },
873                )
874            })
875            .collect();
876        Config {
877            profiles: map,
878            post_prompt: None,
879        }
880    }
881
882    #[test]
883    fn family_name_display_matches_as_str() {
884        let family = FamilyName::new("gpt").unwrap();
885        assert_eq!(family.as_str(), "gpt");
886        // `Display` writes the validated component verbatim.
887        assert_eq!(format!("{family}"), "gpt");
888        assert_eq!(family.to_string(), "gpt");
889    }
890
891    #[test]
892    fn parse_config_file_ignores_non_table_top_level_values() {
893        // A top-level scalar that is not a reserved key defines no profile.
894        let raw = parse_config_file("stray = 1\n").unwrap();
895        assert!(raw.profiles.is_empty());
896    }
897
898    #[test]
899    fn parse_config_file_rejects_duplicate_flattened_profile() {
900        // A literal dotted key and a nested table header flatten to the same
901        // profile name, which is a hard duplicate.
902        let input = "[\"x.y\"]\ndepends_on = []\n\n[x.y]\ndepends_on = []\n";
903        let err = parse_config_file(input).unwrap_err();
904        assert!(
905            matches!(err, PrompterError::DuplicateProfile(_)),
906            "err={err}"
907        );
908    }
909
910    #[test]
911    fn parse_config_file_flattens_empty_root_table_name() {
912        // A quoted empty top-level table name plus a nested child exercises the
913        // empty-prefix arm of the flattener.
914        let input = "[\"\"]\ndepends_on = []\n\n[\"\".child]\ndepends_on = []\n";
915        let raw = parse_config_file(input).unwrap();
916        assert!(raw.profiles.contains_key(""), "profiles={:?}", raw.profiles);
917        assert!(
918            raw.profiles.contains_key("child"),
919            "profiles={:?}",
920            raw.profiles
921        );
922    }
923
924    #[test]
925    fn library_root_for_uses_absolute_library_verbatim() {
926        let cfg_path = mk_tmp("prompter_libroot").join("config.toml");
927        let root = library_root_for(&cfg_path, Some("/abs/lib"), None).unwrap();
928        assert_eq!(root, PathBuf::from("/abs/lib"));
929        assert!(root.is_absolute());
930    }
931
932    #[test]
933    fn list_profiles_json_skips_absent_library_root() {
934        // A library root that does not exist yields an empty fragment listing
935        // rather than an error.
936        let absent = mk_tmp("prompter_absent_root");
937        let cfg = cfg_from_roots(vec![("p", vec![], absent)]);
938        let mut out = Vec::new();
939        list_profiles(&cfg, JsonOutput::Json, &mut out).unwrap();
940        let envelope: serde_json::Value =
941            serde_json::from_str(std::str::from_utf8(&out).unwrap().trim()).unwrap();
942        let libraries = envelope["data"]["libraries"].as_array().unwrap();
943        assert_eq!(libraries.len(), 1);
944        assert!(libraries[0]["fragments"].as_array().unwrap().is_empty());
945    }
946
947    #[test]
948    fn list_profiles_json_errors_when_library_root_is_a_file() {
949        // A library root that exists but is a regular file surfaces the
950        // directory-read failure as a typed I/O error.
951        let dir = mk_tmp("prompter_root_is_file");
952        fs::create_dir_all(&dir).unwrap();
953        let file_root = dir.join("not_a_dir");
954        fs::write(&file_root, b"x").unwrap();
955        let cfg = cfg_from_roots(vec![("p", vec![], file_root)]);
956        let mut out = Vec::new();
957        let err = list_profiles(&cfg, JsonOutput::Json, &mut out).unwrap_err();
958        assert!(matches!(err, PrompterError::Io { .. }), "err={err}");
959        fs::remove_dir_all(&dir).ok();
960    }
961
962    #[test]
963    fn validate_errors_when_library_root_is_unreadable() {
964        // Family-variant inspection reads each library root; a missing root
965        // surfaces a typed I/O error rather than being silently skipped.
966        let absent = mk_tmp("prompter_validate_absent_root");
967        let cfg = cfg_from_roots(vec![("p", vec![], absent)]);
968        let err = validate(&cfg).unwrap_err();
969        assert!(matches!(err, PrompterError::Io { .. }), "err={err}");
970    }
971}