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 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
65// Resolve the operator-facing Canic workspace root from the current directory.
66pub 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(&current_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
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 candidate `canic.toml` files under the conventional workspace App root.
133pub 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
146// Discover candidate `canic.toml` files under one app config root.
147pub 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
165/// Select one config from a canonically admitted discovery result by app name.
166pub 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
203// Recursively collect candidate config paths.
204fn 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
243// Format an actionable config-selection error with whitespace-aligned choices.
244pub(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
280// Prompt interactively for one discovered config when running in a terminal.
281fn 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
323// Render config choices with enough metadata to choose the intended topology.
324fn 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
341// Summarize the Component Spec deployable roles for one install config choice.
342fn 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
358// Format the configured canister count with a bounded role preview.
359fn 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}