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_cli_common::{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<TreeNode>,
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        {
521            if let Ok(rel_path) = path.strip_prefix(root) {
522                fragments.push(rel_path.display().to_string());
523            }
524        }
525    }
526
527    Ok(())
528}
529
530fn inspect_family_variants(
531    directory: &Path,
532    families: &mut HashSet<FamilyName>,
533    errors: &mut Vec<String>,
534) -> Result<(), PrompterError> {
535    let entries = fs::read_dir(directory).map_err(|source| PrompterError::Io {
536        path: directory.to_path_buf(),
537        source,
538    })?;
539
540    for entry in entries {
541        let entry = entry.map_err(|source| PrompterError::Io {
542            path: directory.to_path_buf(),
543            source,
544        })?;
545        let path = entry.path();
546        let file_type = entry.file_type().map_err(|source| PrompterError::Io {
547            path: path.clone(),
548            source,
549        })?;
550        if !file_type.is_dir() {
551            continue;
552        }
553
554        if entry.file_name() == OsStr::new("families") {
555            inspect_families_directory(directory, &path, families, errors)?;
556        } else {
557            inspect_family_variants(&path, families, errors)?;
558        }
559    }
560
561    Ok(())
562}
563
564fn inspect_families_directory(
565    fragment_directory: &Path,
566    families_directory: &Path,
567    families: &mut HashSet<FamilyName>,
568    errors: &mut Vec<String>,
569) -> Result<(), PrompterError> {
570    let entries = fs::read_dir(families_directory).map_err(|source| PrompterError::Io {
571        path: families_directory.to_path_buf(),
572        source,
573    })?;
574
575    for entry in entries {
576        let entry = entry.map_err(|source| PrompterError::Io {
577            path: families_directory.to_path_buf(),
578            source,
579        })?;
580        let family_directory = entry.path();
581        let file_type = entry.file_type().map_err(|source| PrompterError::Io {
582            path: family_directory.clone(),
583            source,
584        })?;
585        if !file_type.is_dir() {
586            continue;
587        }
588
589        let family_os = entry.file_name();
590        let Some(family_text) = family_os.to_str() else {
591            errors.push(format!(
592                "Invalid non-UTF-8 family directory: {}",
593                family_directory.display()
594            ));
595            continue;
596        };
597        let family = match FamilyName::new(family_text) {
598            Ok(family) => family,
599            Err(error) => {
600                errors.push(format!("{error}: {}", family_directory.display()));
601                continue;
602            }
603        };
604        families.insert(family);
605
606        let variants = fs::read_dir(&family_directory).map_err(|source| PrompterError::Io {
607            path: family_directory.clone(),
608            source,
609        })?;
610        for variant in variants {
611            let variant = variant.map_err(|source| PrompterError::Io {
612                path: family_directory.clone(),
613                source,
614            })?;
615            let variant_path = variant.path();
616            let variant_type = variant.file_type().map_err(|source| PrompterError::Io {
617                path: variant_path.clone(),
618                source,
619            })?;
620            if !variant_type.is_file()
621                || !variant_path
622                    .extension()
623                    .is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
624            {
625                continue;
626            }
627
628            let neutral_path = fragment_directory.join(variant.file_name());
629            if !neutral_path.is_file() {
630                errors.push(format!(
631                    "Orphan family variant: {} does not shadow neutral fragment {}",
632                    variant_path.display(),
633                    neutral_path.display()
634                ));
635            }
636        }
637    }
638
639    Ok(())
640}
641
642/// Validate configuration and library file references.
643///
644/// Checks that all profile dependencies are valid, including:
645/// - Referenced profiles exist in configuration
646/// - Referenced markdown files exist in their owning library root
647/// - No circular dependencies exist
648///
649/// # Errors
650/// Returns an error if:
651/// - Referenced profiles don't exist
652/// - Referenced files don't exist
653/// - Circular dependencies are detected
654pub fn validate(cfg: &Config) -> Result<(), PrompterError> {
655    let mut errors: Vec<String> = Vec::new();
656
657    for (profile_name, profile) in &cfg.profiles {
658        for dep in &profile.deps {
659            if std::path::Path::new(dep)
660                .extension()
661                .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
662            {
663                let path = profile.library_root.join(dep);
664                if !path.exists() {
665                    errors.push(format!(
666                        "Missing file: {} (referenced by [{}])",
667                        path.display(),
668                        profile_name
669                    ));
670                }
671            } else if !cfg.profiles.contains_key(dep) {
672                errors.push(format!(
673                    "Unknown profile: {dep} (referenced by [{profile_name}])"
674                ));
675            }
676        }
677    }
678
679    let mut family_set = HashSet::new();
680    for library_root in cfg.library_roots() {
681        inspect_family_variants(&library_root, &mut family_set, &mut errors)?;
682    }
683    let mut families: Vec<_> = family_set.into_iter().collect();
684    families.sort();
685
686    for name in cfg.profiles.keys() {
687        for family in std::iter::once(None).chain(families.iter().map(Some)) {
688            let mut seen_files = HashSet::new();
689            let mut stack = Vec::new();
690            let mut out = Vec::new();
691            if let Err(error) =
692                resolve_profile_for_family(name, cfg, family, &mut seen_files, &mut stack, &mut out)
693            {
694                let message = error.to_string();
695                if !errors.contains(&message) {
696                    errors.push(message);
697                }
698            }
699        }
700    }
701
702    if errors.is_empty() {
703        Ok(())
704    } else {
705        Err(PrompterError::Validation(errors.join("\n")))
706    }
707}
708
709/// Build a tree node for a profile or fragment
710fn build_tree_node(name: &str, cfg: &Config) -> TreeNode {
711    // Check if it's a fragment (ends with .md)
712    if std::path::Path::new(name)
713        .extension()
714        .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
715    {
716        return TreeNode {
717            node_type: TreeNodeType::Fragment,
718            name: name.to_string(),
719            children: Vec::new(),
720        };
721    }
722
723    // It's a profile - recursively build children
724    let children = cfg
725        .profiles
726        .get(name)
727        .map(|def| {
728            def.deps
729                .iter()
730                .map(|dep| build_tree_node(dep, cfg))
731                .collect()
732        })
733        .unwrap_or_default();
734
735    TreeNode {
736        node_type: TreeNodeType::Profile,
737        name: name.to_string(),
738        children,
739    }
740}
741
742/// Find root profiles (profiles that are not referenced by any other profile)
743fn find_root_profiles(cfg: &Config) -> Vec<String> {
744    let mut referenced = HashSet::new();
745
746    // Collect all profiles that are referenced by others
747    for profile in cfg.profiles.values() {
748        for dep in &profile.deps {
749            // Only track profile references (not .md files)
750            if !std::path::Path::new(dep)
751                .extension()
752                .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
753            {
754                referenced.insert(dep.clone());
755            }
756        }
757    }
758
759    // Find profiles that are never referenced
760    let mut roots: Vec<String> = cfg
761        .profiles
762        .keys()
763        .filter(|profile| !referenced.contains(*profile))
764        .cloned()
765        .collect();
766
767    roots.sort();
768    roots
769}
770
771/// Build complete tree structure for all root profiles
772fn build_trees(cfg: &Config) -> TreeOutput {
773    let root_profiles = find_root_profiles(cfg);
774    let trees = root_profiles
775        .iter()
776        .map(|profile| build_tree_node(profile, cfg))
777        .collect();
778
779    TreeOutput { trees }
780}
781
782/// Print tree structure in traditional tree format
783fn print_tree(node: &TreeNode, prefix: &str, is_last: bool, w: &mut impl Write) -> io::Result<()> {
784    // Print current node with appropriate connector
785    let connector = if is_last { "└── " } else { "├── " };
786    writeln!(w, "{prefix}{connector}{}", node.name)?;
787
788    // Prepare prefix for children
789    let child_prefix = format!("{}{}", prefix, if is_last { "    " } else { "│   " });
790
791    // Print children
792    for (i, child) in node.children.iter().enumerate() {
793        let is_last_child = i == node.children.len() - 1;
794        print_tree(child, &child_prefix, is_last_child, w)?;
795    }
796
797    Ok(())
798}
799
800/// Show tree structure for all profiles
801pub fn show_tree(cfg: &Config, output: JsonOutput, mut w: impl Write) -> Result<(), PrompterError> {
802    let trees = build_trees(cfg);
803
804    if output.is_json() {
805        let data = serde_json::to_value(&trees)?;
806        writeln!(
807            &mut w,
808            "{}",
809            render_response("tree", JsonOutput::Json, data, String::new())
810        )
811        .map_err(PrompterError::Write)?;
812    } else {
813        for (i, tree) in trees.trees.iter().enumerate() {
814            // Print root profile name
815            writeln!(&mut w, "{}", tree.name).map_err(PrompterError::Write)?;
816
817            // Print children with tree structure
818            for (j, child) in tree.children.iter().enumerate() {
819                let is_last = j == tree.children.len() - 1;
820                print_tree(child, "", is_last, &mut w).map_err(PrompterError::Write)?;
821            }
822
823            // Add blank line between trees (except after last one)
824            if i < trees.trees.len() - 1 {
825                writeln!(&mut w).map_err(PrompterError::Write)?;
826            }
827        }
828    }
829
830    Ok(())
831}
832
833/// Show tree structure to stdout
834pub fn run_tree_stdout(
835    config_override: Option<&Path>,
836    output: JsonOutput,
837) -> Result<(), PrompterError> {
838    let (_cfg_path, cfg) = load_bundle(config_override)?;
839    show_tree(&cfg, output, io::stdout())
840}