1use crate::release_set::{
2 AppConfigSnapshot, WorkspaceDiscoveryError, display_workspace_path, read_app_config_identity,
3};
4use crate::table::{ColumnAlign, render_table};
5use std::{
6 collections::BTreeMap,
7 env, fs,
8 io::{self, IsTerminal, Write},
9 path::{Path, PathBuf},
10};
11use thiserror::Error as ThisError;
12
13struct ConfigChoiceRow {
18 option: String,
19 config: String,
20 canisters: String,
21}
22
23const CONFIG_CHOICE_ROLE_PREVIEW_LIMIT: usize = 6;
24const APPS_ROOT: &str = "apps";
25const ICP_CONFIG_FILE: &str = "icp.yaml";
26const ROOT_CONFIG_RELATIVE: &str = "canic.toml";
27
28#[derive(Debug, ThisError)]
33pub enum ConfigDiscoveryError {
34 #[error("failed to resolve current directory: {0}")]
35 CurrentDirectory(#[source] io::Error),
36
37 #[error("failed to canonicalize Canic workspace path {path}: {source}")]
38 Canonicalize {
39 path: PathBuf,
40 #[source]
41 source: io::Error,
42 },
43
44 #[error("failed to read Canic config directory {path}: {source}")]
45 Directory {
46 path: PathBuf,
47 #[source]
48 source: io::Error,
49 },
50
51 #[error("failed to inspect Canic config path {path}: {source}")]
52 Path {
53 path: PathBuf,
54 #[source]
55 source: io::Error,
56 },
57
58 #[error("multiple configs declare app {app}: {configs}")]
59 DuplicateApp { app: String, configs: String },
60
61 #[error(transparent)]
62 WorkspaceDiscovery(#[from] WorkspaceDiscoveryError),
63}
64
65pub fn current_canic_workspace_root() -> Result<PathBuf, ConfigDiscoveryError> {
67 let current_dir = env::current_dir().map_err(ConfigDiscoveryError::CurrentDirectory)?;
68 let current_dir =
69 current_dir
70 .canonicalize()
71 .map_err(|source| ConfigDiscoveryError::Canonicalize {
72 path: current_dir,
73 source,
74 })?;
75 Ok(discover_canic_workspace_root_from(¤t_dir)?.unwrap_or(current_dir))
76}
77
78pub fn discover_canic_workspace_root_from(
79 start: &Path,
80) -> Result<Option<PathBuf>, ConfigDiscoveryError> {
81 let mut nearest_apps_root = None;
82 for candidate in start.ancestors() {
83 if !discover_workspace_canic_config_choices(candidate)?.is_empty() {
84 let root =
85 candidate
86 .canonicalize()
87 .map_err(|source| ConfigDiscoveryError::Canonicalize {
88 path: candidate.to_path_buf(),
89 source,
90 })?;
91 if candidate.join(ICP_CONFIG_FILE).is_file() {
92 return Ok(Some(root));
93 }
94 if nearest_apps_root.is_none() {
95 nearest_apps_root = Some(root);
96 }
97 }
98 }
99 Ok(nearest_apps_root)
100}
101
102pub(super) fn resolve_install_config_path(
104 workspace_root: &Path,
105 explicit_config_path: Option<&str>,
106 interactive: bool,
107) -> Result<PathBuf, Box<dyn std::error::Error>> {
108 if let Some(path) = explicit_config_path {
109 let path = PathBuf::from(path);
110 return Ok(if path.is_absolute() {
111 path
112 } else {
113 workspace_root.join(path)
114 });
115 }
116
117 let default = workspace_root.join(APPS_ROOT).join(ROOT_CONFIG_RELATIVE);
118 if default.is_file() {
119 return Ok(default);
120 }
121
122 let choices = discover_workspace_canic_config_choices(workspace_root)?;
123 if interactive
124 && let Some(path) = prompt_install_config_choice(workspace_root, &default, &choices)?
125 {
126 return Ok(path);
127 }
128
129 Err(config_selection_error(workspace_root, &default, &choices).into())
130}
131
132pub fn discover_workspace_canic_config_choices(
134 workspace_root: &Path,
135) -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
136 let mut choices = Vec::new();
137 for root in workspace_app_roots(workspace_root) {
138 collect_canic_config_choices(&root, &mut choices)?;
139 }
140 choices.sort();
141 choices.dedup();
142 reject_duplicate_app_names(&choices)?;
143 Ok(choices)
144}
145
146pub fn discover_canic_config_choices(root: &Path) -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
148 let mut choices = Vec::new();
149 collect_canic_config_choices(root, &mut choices)?;
150 choices.sort();
151 reject_duplicate_app_names(&choices)?;
152 Ok(choices)
153}
154
155#[must_use]
156pub fn workspace_app_roots(workspace_root: &Path) -> Vec<PathBuf> {
157 vec![workspace_root.join(APPS_ROOT)]
158}
159
160fn reject_duplicate_app_names(choices: &[PathBuf]) -> Result<(), ConfigDiscoveryError> {
161 let _ = unique_configs_by_app(choices)?;
162 Ok(())
163}
164
165pub fn select_discovered_app_config_path(
167 choices: &[PathBuf],
168 app: &str,
169) -> Result<Option<PathBuf>, ConfigDiscoveryError> {
170 Ok(unique_configs_by_app(choices)?.remove(app))
171}
172
173fn unique_configs_by_app(
174 choices: &[PathBuf],
175) -> Result<BTreeMap<String, PathBuf>, ConfigDiscoveryError> {
176 let mut by_app = BTreeMap::<String, Vec<&PathBuf>>::new();
177 for path in choices {
178 if let Ok(app) = read_app_config_identity(path) {
179 by_app.entry(app).or_default().push(path);
180 }
181 }
182
183 for (app, paths) in &by_app {
184 if paths.len() > 1 {
185 let configs = paths
186 .iter()
187 .map(|path| path.display().to_string())
188 .collect::<Vec<_>>()
189 .join(", ");
190 return Err(ConfigDiscoveryError::DuplicateApp {
191 app: app.clone(),
192 configs,
193 });
194 }
195 }
196
197 Ok(by_app
198 .into_iter()
199 .filter_map(|(app, mut paths)| paths.pop().cloned().map(|path| (app, path)))
200 .collect())
201}
202
203fn collect_canic_config_choices(
205 root: &Path,
206 choices: &mut Vec<PathBuf>,
207) -> Result<(), ConfigDiscoveryError> {
208 if !root.is_dir() {
209 return Ok(());
210 }
211
212 let entries = fs::read_dir(root).map_err(|source| ConfigDiscoveryError::Directory {
213 path: root.to_path_buf(),
214 source,
215 })?;
216 for entry in entries {
217 let entry = entry.map_err(|source| ConfigDiscoveryError::Directory {
218 path: root.to_path_buf(),
219 source,
220 })?;
221 let path = entry.path();
222 let file_type = entry
223 .file_type()
224 .map_err(|source| ConfigDiscoveryError::Path {
225 path: path.clone(),
226 source,
227 })?;
228 if file_type.is_symlink() {
229 continue;
230 }
231 if file_type.is_dir() {
232 collect_canic_config_choices(&path, choices)?;
233 } else if file_type.is_file()
234 && path.file_name().and_then(|name| name.to_str()) == Some("canic.toml")
235 {
236 choices.push(path);
237 }
238 }
239
240 Ok(())
241}
242
243pub(super) fn config_selection_error(
245 workspace_root: &Path,
246 default: &Path,
247 choices: &[PathBuf],
248) -> String {
249 let mut lines = vec![format!(
250 "missing default Canic config at {}",
251 display_workspace_path(workspace_root, default)
252 )];
253
254 if choices.is_empty() {
255 lines.push("create apps/<app>/canic.toml and run canic install <app> <fleet>".to_string());
256 return lines.join("\n");
257 }
258
259 if choices.len() == 1 {
260 let app = app_name_from_config_path(&choices[0]).unwrap_or("<app>");
261 lines.push(String::new());
262 lines.extend(config_choice_table(workspace_root, choices));
263 lines.push(String::new());
264 lines.push(format!("run: canic install {app} <fleet>"));
265 return lines.join("\n");
266 }
267
268 lines.push("choose an App explicitly:".to_string());
269 lines.push(String::new());
270 lines.extend(config_choice_table(workspace_root, choices));
271 lines.push(String::new());
272 lines.push("run: canic install <app> <fleet>".to_string());
273 lines.join("\n")
274}
275
276fn app_name_from_config_path(path: &Path) -> Option<&str> {
277 path.parent()?.file_name()?.to_str()
278}
279
280fn prompt_install_config_choice(
282 workspace_root: &Path,
283 default: &Path,
284 choices: &[PathBuf],
285) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
286 if choices.is_empty() || !io::stdin().is_terminal() {
287 return Ok(None);
288 }
289
290 eprintln!(
291 "missing default Canic config at {}",
292 display_workspace_path(workspace_root, default)
293 );
294 eprintln!();
295 for line in config_choice_table(workspace_root, choices) {
296 eprintln!("{line}");
297 }
298 eprintln!();
299
300 loop {
301 eprint!("enter config number (ctrl-c to quit): ");
302 io::stderr().flush()?;
303
304 let mut answer = String::new();
305 if io::stdin().read_line(&mut answer)? == 0 {
306 return Ok(None);
307 }
308
309 let trimmed = answer.trim();
310 let Ok(index) = trimmed.parse::<usize>() else {
311 eprintln!("invalid selection: {trimmed}");
312 continue;
313 };
314 let Some(path) = choices.get(index.saturating_sub(1)) else {
315 eprintln!("selection out of range: {index}");
316 continue;
317 };
318
319 return Ok(Some(path.clone()));
320 }
321}
322
323fn config_choice_table(workspace_root: &Path, choices: &[PathBuf]) -> Vec<String> {
325 let rows = choices
326 .iter()
327 .enumerate()
328 .map(|(index, path)| config_choice_row(workspace_root, index + 1, path))
329 .map(|row| [row.option, row.config, row.canisters])
330 .collect::<Vec<_>>();
331 render_table(
332 &["#", "CONFIG", "CANISTERS"],
333 &rows,
334 &[ColumnAlign::Right, ColumnAlign::Left, ColumnAlign::Left],
335 )
336 .lines()
337 .map(str::to_string)
338 .collect()
339}
340
341fn config_choice_row(workspace_root: &Path, option: usize, path: &Path) -> ConfigChoiceRow {
343 let config = display_workspace_path(workspace_root, path);
344 match AppConfigSnapshot::load(path) {
345 Ok(snapshot) => ConfigChoiceRow {
346 option: option.to_string(),
347 config,
348 canisters: format_canister_summary(&snapshot.deployable_roles()),
349 },
350 Err(_) => ConfigChoiceRow {
351 option: option.to_string(),
352 config,
353 canisters: "invalid config".to_string(),
354 },
355 }
356}
357
358fn format_canister_summary(roles: &[String]) -> String {
360 if roles.is_empty() {
361 return "0".to_string();
362 }
363
364 let preview = roles
365 .iter()
366 .take(CONFIG_CHOICE_ROLE_PREVIEW_LIMIT)
367 .map(String::as_str)
368 .collect::<Vec<_>>()
369 .join(", ");
370 let suffix = if roles.len() > CONFIG_CHOICE_ROLE_PREVIEW_LIMIT {
371 ", ..."
372 } else {
373 ""
374 };
375
376 format!("{} ({preview}{suffix})", roles.len())
377}