Skip to main content

tftio_lib/prompt/
profile.rs

1//! Profile resolution, dependency trees, and listing.
2use super::config::Config;
3use super::config::load_bundle;
4use super::{
5    ProfileDef, PrompterError, home_dir, parse_config_file, read_config_with_path, unescape,
6};
7use crate::{JsonOutput, render_response};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::collections::HashSet;
11use std::ffi::OsStr;
12use std::fs;
13use std::io;
14use std::io::Write;
15use std::path::{Component, Path, PathBuf};
16use std::str::FromStr;
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///
803/// Returns an error when tree serialization or writing to the supplied output
804/// fails.
805pub fn show_tree(cfg: &Config, output: JsonOutput, mut w: impl Write) -> Result<(), PrompterError> {
806    let trees = build_trees(cfg);
807
808    if output.is_json() {
809        let data = serde_json::to_value(&trees)?;
810        writeln!(
811            &mut w,
812            "{}",
813            render_response("tree", JsonOutput::Json, data, String::new())
814        )
815        .map_err(PrompterError::Write)?;
816    } else {
817        for (i, tree) in trees.trees.iter().enumerate() {
818            // Print root profile name
819            writeln!(&mut w, "{}", tree.name).map_err(PrompterError::Write)?;
820
821            // Print children with tree structure
822            for (j, child) in tree.children.iter().enumerate() {
823                let is_last = j == tree.children.len() - 1;
824                print_tree(child, "", is_last, &mut w).map_err(PrompterError::Write)?;
825            }
826
827            // Add blank line between trees (except after last one)
828            if i < trees.trees.len() - 1 {
829                writeln!(&mut w).map_err(PrompterError::Write)?;
830            }
831        }
832    }
833
834    Ok(())
835}
836
837/// Show tree structure to stdout.
838///
839/// # Errors
840///
841/// Returns an error when configuration loading, tree serialization, or writing
842/// to standard output fails.
843pub fn run_tree_stdout(
844    config_override: Option<&Path>,
845    output: JsonOutput,
846) -> Result<(), PrompterError> {
847    let (_cfg_path, cfg) = load_bundle(config_override)?;
848    show_tree(&cfg, output, io::stdout())
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    /// Unique, per-call temp path that never collides across tests in this
856    /// module. Mirrors the `src/types.rs` exemplar idiom.
857    fn unique_temp_path(label: &str) -> PathBuf {
858        use std::sync::atomic::{AtomicU32, Ordering};
859        static COUNTER: AtomicU32 = AtomicU32::new(0);
860        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
861        std::env::temp_dir().join(format!(
862            "tftio-lib-profile-{label}-{}-{n}",
863            std::process::id()
864        ))
865    }
866
867    /// Build a [`Config`] whose every profile shares a single library root.
868    fn cfg_with_lib<I>(profiles: I, lib: &Path) -> Config
869    where
870        I: IntoIterator<Item = (&'static str, Vec<&'static str>)>,
871    {
872        let profiles = profiles
873            .into_iter()
874            .map(|(name, deps)| {
875                (
876                    name.to_string(),
877                    ProfileDef {
878                        deps: deps.into_iter().map(String::from).collect(),
879                        library_root: lib.to_path_buf(),
880                    },
881                )
882            })
883            .collect();
884        Config {
885            profiles,
886            post_prompt: None,
887        }
888    }
889
890    #[test]
891    fn family_name_display_renders_inner_value() {
892        let family = FamilyName::new("gpt").unwrap();
893        assert_eq!(family.to_string(), "gpt");
894        assert_eq!(format!("{family}"), "gpt");
895    }
896
897    #[test]
898    fn collect_profiles_ignores_non_table_value() {
899        let mut out: HashMap<String, Vec<String>> = HashMap::new();
900        collect_profiles("scalar", &toml::Value::Integer(5), &mut out).unwrap();
901        assert!(out.is_empty());
902    }
903
904    #[test]
905    fn collect_profiles_rejects_non_string_depends_on_entries() {
906        let value: toml::Value = toml::from_str("depends_on = [1]").unwrap();
907        let mut out: HashMap<String, Vec<String>> = HashMap::new();
908        let err = collect_profiles("p", &value, &mut out).unwrap_err();
909        assert!(matches!(err, PrompterError::ConfigField(_)));
910        assert!(err.to_string().contains("must be strings"), "err={err}");
911    }
912
913    #[test]
914    fn collect_profiles_detects_duplicate_prefix() {
915        let value: toml::Value = toml::from_str("depends_on = [\"a.md\"]").unwrap();
916        let mut out: HashMap<String, Vec<String>> = HashMap::new();
917        out.insert("p".to_string(), vec!["preexisting".to_string()]);
918        let err = collect_profiles("p", &value, &mut out).unwrap_err();
919        assert!(matches!(err, PrompterError::DuplicateProfile(_)));
920        assert!(err.to_string().contains("Duplicate profile"), "err={err}");
921    }
922
923    #[test]
924    fn collect_profiles_handles_empty_prefix() {
925        // With an empty prefix the nested key becomes the bare profile name
926        // rather than a dotted path.
927        let value: toml::Value = toml::from_str("[x]\ndepends_on = [\"a.md\"]").unwrap();
928        let mut out: HashMap<String, Vec<String>> = HashMap::new();
929        collect_profiles("", &value, &mut out).unwrap();
930        assert_eq!(out.get("x").map(Vec::len), Some(1));
931        assert_eq!(
932            out.get("x").map(Vec::as_slice),
933            Some(["a.md".to_string()].as_slice())
934        );
935    }
936
937    #[test]
938    fn load_config_bundle_resolves_relative_import() {
939        let dir = unique_temp_path("relimport");
940        fs::create_dir_all(&dir).unwrap();
941        fs::write(dir.join("child.toml"), "[imported]\ndepends_on = []\n").unwrap();
942        fs::write(
943            dir.join("config.toml"),
944            "import = [\"child.toml\"]\n\n[main]\ndepends_on = []\n",
945        )
946        .unwrap();
947
948        let cfg = load_config_bundle(&dir.join("config.toml"), None).unwrap();
949        assert!(cfg.profiles.contains_key("main"));
950        assert!(cfg.profiles.contains_key("imported"));
951
952        fs::remove_dir_all(&dir).ok();
953    }
954
955    #[test]
956    fn load_config_bundle_honors_absolute_library_key() {
957        let dir = unique_temp_path("abslib-cfg");
958        fs::create_dir_all(&dir).unwrap();
959        let abs_lib = unique_temp_path("abslib-target");
960        fs::create_dir_all(&abs_lib).unwrap();
961        assert!(abs_lib.is_absolute());
962
963        let config_text = format!(
964            "library = \"{}\"\n\n[p]\ndepends_on = []\n",
965            abs_lib.display()
966        );
967        fs::write(dir.join("config.toml"), config_text).unwrap();
968
969        let cfg = load_config_bundle(&dir.join("config.toml"), None).unwrap();
970        assert_eq!(cfg.profiles.get("p").unwrap().library_root, abs_lib);
971
972        fs::remove_dir_all(&dir).ok();
973        fs::remove_dir_all(&abs_lib).ok();
974    }
975
976    #[test]
977    fn list_profiles_json_emits_sorted_libraries_and_fragments() {
978        let lib = unique_temp_path("list-json");
979        fs::create_dir_all(lib.join("sub")).unwrap();
980        fs::write(lib.join("top.md"), b"T").unwrap();
981        fs::write(lib.join("sub/nested.md"), b"N").unwrap();
982        // A non-markdown sibling must be excluded from the fragment listing.
983        fs::write(lib.join("notes.txt"), b"ignore").unwrap();
984
985        // Two profiles share one library root, exercising both the insert and
986        // the dedup-skip paths of the unique-root gather.
987        let cfg = cfg_with_lib(
988            [("beta", vec!["top.md"]), ("alpha", vec!["sub/nested.md"])],
989            &lib,
990        );
991
992        let mut out = Vec::new();
993        list_profiles(&cfg, JsonOutput::Json, &mut out).unwrap();
994        let text = String::from_utf8(out).unwrap();
995        let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
996
997        assert_eq!(value["ok"], serde_json::json!(true));
998        assert_eq!(value["command"], serde_json::json!("list"));
999
1000        let profiles = value["data"]["profiles"].as_array().unwrap();
1001        assert_eq!(profiles.len(), 2);
1002        assert_eq!(profiles[0]["name"], serde_json::json!("alpha"));
1003        assert_eq!(profiles[1]["name"], serde_json::json!("beta"));
1004
1005        let libraries = value["data"]["libraries"].as_array().unwrap();
1006        assert_eq!(libraries.len(), 1);
1007        assert_eq!(
1008            libraries[0]["path"],
1009            serde_json::json!(lib.display().to_string())
1010        );
1011        let fragments: Vec<&str> = libraries[0]["fragments"]
1012            .as_array()
1013            .unwrap()
1014            .iter()
1015            .map(|f| f.as_str().unwrap())
1016            .collect();
1017        assert_eq!(fragments, vec!["sub/nested.md", "top.md"]);
1018
1019        fs::remove_dir_all(&lib).ok();
1020    }
1021
1022    #[test]
1023    fn collect_fragments_errors_on_missing_directory() {
1024        let missing = unique_temp_path("collect-missing");
1025        let mut fragments = Vec::new();
1026        let err = collect_fragments(&missing, &missing, &mut fragments).unwrap_err();
1027        match err {
1028            PrompterError::Io { path, .. } => assert_eq!(path, missing),
1029            other => panic!("expected Io error, got {other:?}"),
1030        }
1031    }
1032
1033    #[test]
1034    fn inspect_family_variants_errors_on_missing_directory() {
1035        let missing = unique_temp_path("inspect-missing");
1036        let mut families = HashSet::new();
1037        let mut errors = Vec::new();
1038        let err = inspect_family_variants(&missing, &mut families, &mut errors).unwrap_err();
1039        match err {
1040            PrompterError::Io { path, .. } => assert_eq!(path, missing),
1041            other => panic!("expected Io error, got {other:?}"),
1042        }
1043    }
1044
1045    #[test]
1046    fn inspect_families_directory_errors_on_missing_directory() {
1047        let fragment_dir = unique_temp_path("families-frag");
1048        let missing_families = fragment_dir.join("families");
1049        let mut families = HashSet::new();
1050        let mut errors = Vec::new();
1051        let err = inspect_families_directory(
1052            &fragment_dir,
1053            &missing_families,
1054            &mut families,
1055            &mut errors,
1056        )
1057        .unwrap_err();
1058        match err {
1059            PrompterError::Io { path, .. } => assert_eq!(path, missing_families),
1060            other => panic!("expected Io error, got {other:?}"),
1061        }
1062    }
1063
1064    #[test]
1065    fn validate_skips_non_directory_family_entries() {
1066        let lib = unique_temp_path("family-skips");
1067        fs::create_dir_all(lib.join("general/families/gpt")).unwrap();
1068        fs::write(lib.join("general/rules.md"), b"NEUTRAL").unwrap();
1069        // Variant that correctly shadows the neutral fragment (no orphan).
1070        fs::write(lib.join("general/families/gpt/rules.md"), b"GPT").unwrap();
1071        // A plain file directly under `families/` is not a family directory.
1072        fs::write(lib.join("general/families/README.txt"), b"not a dir").unwrap();
1073        // A non-markdown entry inside a family directory is ignored.
1074        fs::write(lib.join("general/families/gpt/notes.txt"), b"not md").unwrap();
1075
1076        let cfg = cfg_with_lib([("root", vec!["general/rules.md"])], &lib);
1077        assert!(validate(&cfg).is_ok());
1078
1079        fs::remove_dir_all(&lib).ok();
1080    }
1081
1082    #[test]
1083    fn show_tree_text_renders_deterministic_tree() {
1084        // `build_tree_node`/`find_root_profiles` do not touch the filesystem;
1085        // the library root is only a struct field here.
1086        let lib = unique_temp_path("tree-text");
1087        let cfg = cfg_with_lib(
1088            [
1089                ("alpha", vec!["beta", "x.md", "phantom"]),
1090                ("beta", vec!["y.md", "z.md"]),
1091                ("gamma", vec!["w.md"]),
1092            ],
1093            &lib,
1094        );
1095
1096        let mut out = Vec::new();
1097        show_tree(&cfg, JsonOutput::Text, &mut out).unwrap();
1098        let text = String::from_utf8(out).unwrap();
1099
1100        // Roots are sorted (alpha, gamma); `phantom` is an unknown profile with
1101        // no children; connectors and prefixes are deterministic.
1102        let expected = concat!(
1103            "alpha\n",
1104            "├── beta\n",
1105            "│   ├── y.md\n",
1106            "│   └── z.md\n",
1107            "├── x.md\n",
1108            "└── phantom\n",
1109            "\n",
1110            "gamma\n",
1111            "└── w.md\n",
1112        );
1113        assert_eq!(text, expected);
1114    }
1115
1116    #[test]
1117    fn show_tree_json_serializes_trees() {
1118        let lib = unique_temp_path("tree-json");
1119        let cfg = cfg_with_lib(
1120            [("alpha", vec!["beta", "x.md"]), ("beta", vec!["y.md"])],
1121            &lib,
1122        );
1123
1124        let mut out = Vec::new();
1125        show_tree(&cfg, JsonOutput::Json, &mut out).unwrap();
1126        let text = String::from_utf8(out).unwrap();
1127        let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1128
1129        assert_eq!(value["ok"], serde_json::json!(true));
1130        assert_eq!(value["command"], serde_json::json!("tree"));
1131
1132        let trees = value["data"]["trees"].as_array().unwrap();
1133        assert_eq!(trees.len(), 1);
1134        assert_eq!(trees[0]["name"], serde_json::json!("alpha"));
1135        assert_eq!(trees[0]["type"], serde_json::json!("profile"));
1136
1137        let children = trees[0]["children"].as_array().unwrap();
1138        assert_eq!(children[0]["name"], serde_json::json!("beta"));
1139        assert_eq!(children[0]["type"], serde_json::json!("profile"));
1140        assert_eq!(
1141            children[0]["children"][0]["name"],
1142            serde_json::json!("y.md")
1143        );
1144        assert_eq!(
1145            children[0]["children"][0]["type"],
1146            serde_json::json!("fragment")
1147        );
1148        assert_eq!(children[1]["name"], serde_json::json!("x.md"));
1149        assert_eq!(children[1]["type"], serde_json::json!("fragment"));
1150        // A leaf fragment serializes without an empty `children` array.
1151        assert!(children[1].get("children").is_none());
1152    }
1153
1154    #[test]
1155    fn run_tree_stdout_with_explicit_config_succeeds() {
1156        let root = unique_temp_path("tree-stdout");
1157        fs::create_dir_all(root.join("library/a")).unwrap();
1158        fs::write(root.join("library/a/x.md"), b"AX").unwrap();
1159        let config = root.join("config.toml");
1160        fs::write(
1161            &config,
1162            "[child]\ndepends_on = [\"a/x.md\"]\n\n[root]\ndepends_on = [\"child\"]\n",
1163        )
1164        .unwrap();
1165
1166        assert!(run_tree_stdout(Some(&config), JsonOutput::Text).is_ok());
1167        assert!(run_tree_stdout(Some(&config), JsonOutput::Json).is_ok());
1168
1169        fs::remove_dir_all(&root).ok();
1170    }
1171}