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