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 project 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_project_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_project_root_from(¤t_dir)?.unwrap_or(current_dir))
76}
77
78pub fn discover_canic_project_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_project_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(super) fn discover_workspace_canic_config_choices(
134 workspace_root: &Path,
135) -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
136 discover_project_canic_config_choices(workspace_root)
137}
138
139pub fn discover_project_canic_config_choices(
141 project_root: &Path,
142) -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
143 let mut choices = Vec::new();
144 for root in project_app_roots(project_root) {
145 collect_canic_config_choices(&root, &mut choices)?;
146 }
147 choices.sort();
148 choices.dedup();
149 reject_duplicate_app_names(&choices)?;
150 Ok(choices)
151}
152
153pub fn discover_canic_config_choices(root: &Path) -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
155 let mut choices = Vec::new();
156 collect_canic_config_choices(root, &mut choices)?;
157 choices.sort();
158 reject_duplicate_app_names(&choices)?;
159 Ok(choices)
160}
161
162#[must_use]
163pub fn project_app_roots(project_root: &Path) -> Vec<PathBuf> {
164 vec![project_root.join(APPS_ROOT)]
165}
166
167fn reject_duplicate_app_names(choices: &[PathBuf]) -> Result<(), ConfigDiscoveryError> {
168 let _ = unique_configs_by_app(choices)?;
169 Ok(())
170}
171
172pub fn select_discovered_app_config_path(
174 choices: &[PathBuf],
175 app: &str,
176) -> Result<Option<PathBuf>, ConfigDiscoveryError> {
177 Ok(unique_configs_by_app(choices)?.remove(app))
178}
179
180fn unique_configs_by_app(
181 choices: &[PathBuf],
182) -> Result<BTreeMap<String, PathBuf>, ConfigDiscoveryError> {
183 let mut by_app = BTreeMap::<String, Vec<&PathBuf>>::new();
184 for path in choices {
185 if let Ok(app) = read_app_config_identity(path) {
186 by_app.entry(app).or_default().push(path);
187 }
188 }
189
190 for (app, paths) in &by_app {
191 if paths.len() > 1 {
192 let configs = paths
193 .iter()
194 .map(|path| path.display().to_string())
195 .collect::<Vec<_>>()
196 .join(", ");
197 return Err(ConfigDiscoveryError::DuplicateApp {
198 app: app.clone(),
199 configs,
200 });
201 }
202 }
203
204 Ok(by_app
205 .into_iter()
206 .filter_map(|(app, mut paths)| paths.pop().cloned().map(|path| (app, path)))
207 .collect())
208}
209
210fn collect_canic_config_choices(
212 root: &Path,
213 choices: &mut Vec<PathBuf>,
214) -> Result<(), ConfigDiscoveryError> {
215 if !root.is_dir() {
216 return Ok(());
217 }
218
219 let entries = fs::read_dir(root).map_err(|source| ConfigDiscoveryError::Directory {
220 path: root.to_path_buf(),
221 source,
222 })?;
223 for entry in entries {
224 let entry = entry.map_err(|source| ConfigDiscoveryError::Directory {
225 path: root.to_path_buf(),
226 source,
227 })?;
228 let path = entry.path();
229 let file_type = entry
230 .file_type()
231 .map_err(|source| ConfigDiscoveryError::Path {
232 path: path.clone(),
233 source,
234 })?;
235 if file_type.is_symlink() {
236 continue;
237 }
238 if file_type.is_dir() {
239 collect_canic_config_choices(&path, choices)?;
240 } else if file_type.is_file()
241 && path.file_name().and_then(|name| name.to_str()) == Some("canic.toml")
242 {
243 choices.push(path);
244 }
245 }
246
247 Ok(())
248}
249
250pub(super) fn config_selection_error(
252 workspace_root: &Path,
253 default: &Path,
254 choices: &[PathBuf],
255) -> String {
256 let mut lines = vec![format!(
257 "missing default Canic config at {}",
258 display_workspace_path(workspace_root, default)
259 )];
260
261 if choices.is_empty() {
262 lines.push("create apps/<app>/canic.toml and run canic install <app> <fleet>".to_string());
263 return lines.join("\n");
264 }
265
266 if choices.len() == 1 {
267 let app = app_name_from_config_path(&choices[0]).unwrap_or("<app>");
268 lines.push(String::new());
269 lines.extend(config_choice_table(workspace_root, choices));
270 lines.push(String::new());
271 lines.push(format!("run: canic install {app} <fleet>"));
272 return lines.join("\n");
273 }
274
275 lines.push("choose an App explicitly:".to_string());
276 lines.push(String::new());
277 lines.extend(config_choice_table(workspace_root, choices));
278 lines.push(String::new());
279 lines.push("run: canic install <app> <fleet>".to_string());
280 lines.join("\n")
281}
282
283fn app_name_from_config_path(path: &Path) -> Option<&str> {
284 path.parent()?.file_name()?.to_str()
285}
286
287fn prompt_install_config_choice(
289 workspace_root: &Path,
290 default: &Path,
291 choices: &[PathBuf],
292) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
293 if choices.is_empty() || !io::stdin().is_terminal() {
294 return Ok(None);
295 }
296
297 eprintln!(
298 "missing default Canic config at {}",
299 display_workspace_path(workspace_root, default)
300 );
301 eprintln!();
302 for line in config_choice_table(workspace_root, choices) {
303 eprintln!("{line}");
304 }
305 eprintln!();
306
307 loop {
308 eprint!("enter config number (ctrl-c to quit): ");
309 io::stderr().flush()?;
310
311 let mut answer = String::new();
312 if io::stdin().read_line(&mut answer)? == 0 {
313 return Ok(None);
314 }
315
316 let trimmed = answer.trim();
317 let Ok(index) = trimmed.parse::<usize>() else {
318 eprintln!("invalid selection: {trimmed}");
319 continue;
320 };
321 let Some(path) = choices.get(index.saturating_sub(1)) else {
322 eprintln!("selection out of range: {index}");
323 continue;
324 };
325
326 return Ok(Some(path.clone()));
327 }
328}
329
330fn config_choice_table(workspace_root: &Path, choices: &[PathBuf]) -> Vec<String> {
332 let rows = choices
333 .iter()
334 .enumerate()
335 .map(|(index, path)| config_choice_row(workspace_root, index + 1, path))
336 .map(|row| [row.option, row.config, row.canisters])
337 .collect::<Vec<_>>();
338 render_table(
339 &["#", "CONFIG", "CANISTERS"],
340 &rows,
341 &[ColumnAlign::Right, ColumnAlign::Left, ColumnAlign::Left],
342 )
343 .lines()
344 .map(str::to_string)
345 .collect()
346}
347
348fn config_choice_row(workspace_root: &Path, option: usize, path: &Path) -> ConfigChoiceRow {
350 let config = display_workspace_path(workspace_root, path);
351 match AppConfigSnapshot::load(path) {
352 Ok(snapshot) => ConfigChoiceRow {
353 option: option.to_string(),
354 config,
355 canisters: format_canister_summary(&snapshot.deployable_roles()),
356 },
357 Err(_) => ConfigChoiceRow {
358 option: option.to_string(),
359 config,
360 canisters: "invalid config".to_string(),
361 },
362 }
363}
364
365fn format_canister_summary(roles: &[String]) -> String {
367 if roles.is_empty() {
368 return "0".to_string();
369 }
370
371 let preview = roles
372 .iter()
373 .take(CONFIG_CHOICE_ROLE_PREVIEW_LIMIT)
374 .map(String::as_str)
375 .collect::<Vec<_>>()
376 .join(", ");
377 let suffix = if roles.len() > CONFIG_CHOICE_ROLE_PREVIEW_LIMIT {
378 ", ..."
379 } else {
380 ""
381 };
382
383 format!("{} ({preview}{suffix})", roles.len())
384}