Skip to main content

canic_host/install_root/
config_selection.rs

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
13///
14/// ConfigChoiceRow
15///
16
17struct 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///
29/// ConfigDiscoveryError
30///
31
32#[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
65// Resolve the operator-facing Canic project root from the current directory.
66pub 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(&current_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
102// Resolve install config selection without silently choosing among demo/test configs.
103pub(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
132// Discover installable Canic config choices from the app root.
133pub(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
139// Discover candidate `canic.toml` files under conventional project app roots.
140pub 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
153// Discover candidate `canic.toml` files under one app config root.
154pub 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
172/// Select one config from a canonically admitted discovery result by app name.
173pub 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
210// Recursively collect candidate config paths.
211fn 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
250// Format an actionable config-selection error with whitespace-aligned choices.
251pub(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
287// Prompt interactively for one discovered config when running in a terminal.
288fn 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
330// Render config choices with enough metadata to choose the intended topology.
331fn 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
348// Summarize the Component Spec deployable roles for one install config choice.
349fn 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
365// Format the configured canister count with a bounded role preview.
366fn 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}