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 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 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 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}