Skip to main content

canic_host/install_root/
config_selection.rs

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
12///
13/// ConfigChoiceRow
14///
15
16struct 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///
28/// ConfigDiscoveryError
29///
30
31#[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
64// Resolve the operator-facing Canic project root from the current directory.
65pub 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(&current_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
101// Resolve install config selection without silently choosing among demo/test configs.
102pub(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
129// Discover installable Canic config choices from the app root.
130pub(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
136// Discover candidate `canic.toml` files under conventional project app roots.
137pub 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
150// Discover candidate `canic.toml` files under one app config root.
151pub 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
169/// Select one config from a canonically admitted discovery result by app name.
170pub 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
207// Recursively collect candidate config paths.
208fn 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
247// Format an actionable config-selection error with whitespace-aligned choices.
248pub(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
284// Prompt interactively for one discovered config when running in a terminal.
285fn 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
327// Render config choices with enough metadata to choose the intended topology.
328fn 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
345// Summarize the root-subnet deployable roles for one install config choice.
346fn 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
362// Format the root-subnet canister count with a bounded role preview.
363fn 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
383// Render a workspace-relative path where possible for concise diagnostics.
384fn 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}