Skip to main content

cli/apps/
info.rs

1use super::{installed_content_hash, metadata, resolve_install_destination};
2use crate::colors;
3use crate::config::Config;
4use crate::install_core::manifest::AppManifest;
5use crate::path_display;
6use anyhow::Result;
7
8use super::AppListMode;
9
10pub async fn handle_info(config: &Config, category: &str) -> Result<()> {
11    crate::config::print_presets_note(config);
12    let categories = metadata::load_active_categories(config, Some(category)).await?;
13    let cat = categories
14        .iter()
15        .find(|c| c.name == category)
16        .ok_or_else(|| anyhow::anyhow!("app preset category not found: {category}"))?;
17
18    let manifest = AppManifest::load(config.shine_dir()).await?;
19
20    // Header
21    if let Some(desc) = &cat.description {
22        println!("{}  {}", colors::bold(&cat.name), colors::dim(desc));
23    } else {
24        println!("{}", colors::bold(&cat.name));
25    }
26    println!();
27
28    if let Some(dest_root) = &cat.destination_root {
29        println!(
30            "  {}  {}",
31            colors::dim("Destination"),
32            path_display::format_tilde_path(dest_root, &config.home_dir)
33        );
34    }
35    println!("  {}  {}", colors::dim("Files      "), cat.files.len());
36    println!();
37
38    let col_width = cat
39        .files
40        .iter()
41        .map(|f| f.source_rel.display().to_string().len())
42        .max()
43        .unwrap_or(0);
44
45    let mut any_installed = false;
46
47    for file in &cat.files {
48        let source_name = file.source_rel.display().to_string();
49        let padding = " ".repeat(col_width.saturating_sub(source_name.len()));
50
51        // Not migrated to status::app_entry_status: this inline computation
52        // distinguishes "missing on disk" (read error) from JsonMerge's
53        // "missing managed keys" (Ok(None)) with separate wording, and never
54        // checks source_hash_for_file for an available update — collapsing
55        // it onto the shared FileStatus enum (which conflates both missing
56        // cases into FileStatus::Missing and would newly report
57        // UpdateAvail here) would be a real behavior change, not a
58        // same-behavior extraction. Left as a known, documented duplicate.
59        let dest_str = match resolve_install_destination(cat, file, config) {
60            Ok(dest) => {
61                let status = match manifest.find_by_dest(&dest) {
62                    None => String::new(),
63                    Some(entry) => {
64                        any_installed = true;
65                        match tokio::fs::read(&dest).await {
66                            Ok(bytes) => match installed_content_hash(file, &bytes) {
67                                Ok(Some(hash)) if hash == entry.content_hash => {
68                                    format!("  {}", colors::green("installed, up to date"))
69                                }
70                                Ok(None) => {
71                                    format!(
72                                        "  {}",
73                                        colors::yellow("installed, missing managed keys")
74                                    )
75                                }
76                                Ok(Some(_)) | Err(_) => {
77                                    format!("  {}", colors::yellow("installed, user-modified"))
78                                }
79                            },
80                            Err(_) => {
81                                format!("  {}", colors::yellow("installed, missing on disk"))
82                            }
83                        }
84                    }
85                };
86                format!(
87                    "{}  {}{}",
88                    colors::dim("→"),
89                    colors::dim(&path_display::format_home(&dest, &config.home_dir)),
90                    status
91                )
92            }
93            Err(_) => colors::dim("(destination unresolvable)"),
94        };
95
96        let file_desc = file
97            .description
98            .as_deref()
99            .map(|d| format!("  {}", colors::dim(d)))
100            .unwrap_or_default();
101
102        println!("  {source_name}{padding}  {dest_str}{file_desc}");
103    }
104
105    println!();
106    if any_installed {
107        println!(
108            "{}",
109            colors::dim(&format!(
110                "Installed. Run `shine install app/{category} --replace-managed` to repair managed files."
111            ))
112        );
113    } else {
114        println!(
115            "{}",
116            colors::dim(&format!(
117                "Not installed. Run `shine install app/{category}` to install."
118            ))
119        );
120    }
121
122    Ok(())
123}
124
125pub async fn handle_list(config: &Config) -> Result<()> {
126    handle_list_with_presets_note(config, true).await
127}
128
129#[doc(hidden)]
130pub async fn handle_list_with_presets_note(
131    config: &Config,
132    print_presets_note: bool,
133) -> Result<()> {
134    if print_presets_note {
135        crate::config::print_presets_note(config);
136    }
137    let categories = metadata::load_active_categories(config, None).await?;
138
139    if categories.is_empty() {
140        println!("{}", colors::dim("No app preset categories found."));
141        return Ok(());
142    }
143
144    println!("{}\n", colors::bold("App Preset Categories"));
145
146    let name_width = categories.iter().map(|c| c.name.len()).max().unwrap_or(0);
147
148    for cat in &categories {
149        let effective_desc = cat.description.as_deref().or_else(|| {
150            if cat.files.len() == 1 {
151                cat.files[0].description.as_deref()
152            } else {
153                None
154            }
155        });
156
157        let name_pad = " ".repeat(name_width.saturating_sub(cat.name.len()));
158        let file_count = if cat.files.len() > 1 {
159            format!("  {}", colors::dim(&format!("{} files", cat.files.len())))
160        } else {
161            String::new()
162        };
163
164        let desc_part = effective_desc.map(|d| format!("  {d}")).unwrap_or_default();
165
166        println!("  {}{}{}{}", cat.name, name_pad, desc_part, file_count);
167
168        // Per-file rows for explicit multi-file categories
169        if cat.has_explicit_files && cat.list_mode == AppListMode::Files && cat.files.len() > 1 {
170            for file in &cat.files {
171                let name = file.source_rel.display().to_string();
172                if let Some(desc) = &file.description {
173                    println!("    {}  {}", colors::dim(&name), colors::dim(desc));
174                } else {
175                    println!("    {}", colors::dim(&name));
176                }
177            }
178        }
179    }
180
181    println!();
182    println!(
183        "{}",
184        colors::dim("Run `shine install app/<CATEGORY>` to install a specific category.")
185    );
186    println!("{}", colors::dim("Run `shine app install` to install all."));
187
188    Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn list_uses_embedded_metadata_for_vim() {
197        let categories = metadata::load_embedded_categories(Some("vim")).unwrap();
198        let vim = categories.iter().find(|c| c.name == "vim").unwrap();
199        assert!(vim.uses_metadata);
200        assert_eq!(vim.destination_root.as_deref(), Some("~/.vim"));
201    }
202}