Skip to main content

canic_host/icp_config/
mod.rs

1use crate::{
2    install_root::{
3        ConfigDiscoveryError, current_canic_project_root, discover_project_canic_config_choices,
4        project_fleet_roots,
5    },
6    release_set::{FleetConfigError, configured_deployable_roles, configured_fleet_name, icp_root},
7    workspace_discovery::discover_icp_root_from,
8};
9use std::{
10    collections::{BTreeMap, BTreeSet},
11    fs,
12    path::{Path, PathBuf},
13};
14use thiserror::Error as ThisError;
15
16const ICP_CONFIG_FILE: &str = "icp.yaml";
17pub const DEFAULT_LOCAL_GATEWAY_PORT: u16 = 8000;
18
19///
20/// IcpBuildEnvironment
21///
22/// Build-time network class baked into Canic Wasm artifacts.
23///
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum IcpBuildEnvironment {
27    Ic,
28    Local,
29}
30
31impl IcpBuildEnvironment {
32    #[must_use]
33    pub const fn as_str(self) -> &'static str {
34        match self {
35            Self::Ic => "ic",
36            Self::Local => "local",
37        }
38    }
39}
40
41///
42/// IcpConfigError
43///
44
45#[derive(Debug, ThisError)]
46pub enum IcpConfigError {
47    #[error("could not find icp.yaml from {}", start.display())]
48    NoIcpRoot { start: PathBuf },
49
50    #[error("{0}")]
51    Config(String),
52
53    #[error(transparent)]
54    ConfigDiscovery(#[from] ConfigDiscoveryError),
55
56    #[error(transparent)]
57    FleetConfig(#[from] FleetConfigError),
58
59    #[error(transparent)]
60    Io(#[from] std::io::Error),
61}
62
63///
64/// IcpProjectConfigReport
65///
66
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct IcpProjectConfigReport {
69    pub path: PathBuf,
70    pub icp_root: PathBuf,
71    pub icp_yaml_present: bool,
72    pub canisters: Vec<String>,
73    pub environments: Vec<String>,
74    pub missing_canisters: Vec<String>,
75    pub missing_environments: Vec<String>,
76    pub local_network_present: bool,
77}
78
79impl IcpProjectConfigReport {
80    #[must_use]
81    pub const fn is_ready(&self) -> bool {
82        self.icp_yaml_present
83            && self.local_network_present
84            && self.missing_canisters.is_empty()
85            && self.missing_environments.is_empty()
86    }
87
88    #[must_use]
89    pub fn issues(&self) -> Vec<String> {
90        let mut issues = Vec::new();
91        if !self.icp_yaml_present {
92            issues.push(format!("missing {}", self.path.display()));
93        }
94        if !self.local_network_present {
95            issues.push("missing local network entry".to_string());
96        }
97        if !self.missing_canisters.is_empty() {
98            issues.push(format!(
99                "missing canisters: {}",
100                self.missing_canisters.join(", ")
101            ));
102        }
103        if !self.missing_environments.is_empty() {
104            issues.push(format!(
105                "missing environments: {}",
106                self.missing_environments.join(", ")
107            ));
108        }
109        issues
110    }
111}
112
113/// Return the configured local ICP gateway port, falling back to ICP's default.
114pub(crate) fn configured_local_gateway_port() -> Result<u16, IcpConfigError> {
115    let root = current_icp_root()?;
116    configured_local_gateway_port_from_root(&root)
117}
118
119/// Return the configured local ICP gateway port for one ICP project root.
120pub fn configured_local_gateway_port_from_root(root: &Path) -> Result<u16, IcpConfigError> {
121    let source = fs::read_to_string(root.join(ICP_CONFIG_FILE))?;
122    Ok(local_gateway_port_from_yaml(&source))
123}
124
125/// Resolve an ICP environment name to the build-time network class used by Canic.
126///
127/// The implicit `local` and `ic` environments resolve without project config.
128/// Named environments must exist in `icp.yaml`; their declared network decides
129/// whether Cargo builds a local/test artifact or an IC mainnet artifact.
130pub fn resolve_icp_build_environment_from_root(
131    root: &Path,
132    environment: &str,
133) -> Result<IcpBuildEnvironment, IcpConfigError> {
134    let environment = environment.trim();
135    if environment.is_empty() {
136        return Err(IcpConfigError::Config(
137            "ICP environment name must not be empty".to_string(),
138        ));
139    }
140    match environment {
141        "local" => return Ok(IcpBuildEnvironment::Local),
142        "ic" => return Ok(IcpBuildEnvironment::Ic),
143        _ => {}
144    }
145
146    let path = root.join(ICP_CONFIG_FILE);
147    let source = fs::read_to_string(&path).map_err(|err| {
148        if err.kind() == std::io::ErrorKind::NotFound {
149            IcpConfigError::Config(format!(
150                "ICP environment '{environment}' cannot be resolved because {} is missing",
151                path.display()
152            ))
153        } else {
154            IcpConfigError::Io(err)
155        }
156    })?;
157    resolve_icp_build_environment_from_yaml(&source, environment)
158        .map_err(|message| IcpConfigError::Config(format!("{}: {message}", path.display())))
159}
160
161/// Inspect whether `icp.yaml` contains the entries implied by Canic fleet configs.
162pub fn inspect_canic_icp_yaml(
163    fleet_filter: Option<&str>,
164) -> Result<IcpProjectConfigReport, IcpConfigError> {
165    let root = resolve_current_canic_icp_root()?;
166    inspect_canic_icp_yaml_from_root(&root, fleet_filter)
167}
168
169/// Inspect one ICP project root without mutating its `icp.yaml`.
170pub fn inspect_canic_icp_yaml_from_root(
171    root: &Path,
172    fleet_filter: Option<&str>,
173) -> Result<IcpProjectConfigReport, IcpConfigError> {
174    let path = root.join(ICP_CONFIG_FILE);
175    let (source, icp_yaml_present) = read_optional_icp_yaml(&path)?;
176    let spec = discover_project_spec(root, fleet_filter)?;
177    let configured_canisters = top_level_named_items(&source, "canisters:");
178    let configured_environments = top_level_named_items(&source, "environments:");
179    let lines = source.lines().collect::<Vec<_>>();
180    let local_network_present = local_network_block(&lines).is_some();
181
182    let missing_canisters = spec
183        .canisters
184        .iter()
185        .filter(|name| !configured_canisters.contains(*name))
186        .cloned()
187        .collect::<Vec<_>>();
188    let missing_environments = spec
189        .environments
190        .keys()
191        .filter(|name| !configured_environments.contains(*name))
192        .cloned()
193        .collect::<Vec<_>>();
194
195    Ok(IcpProjectConfigReport {
196        path,
197        icp_root: root.to_path_buf(),
198        icp_yaml_present,
199        canisters: spec.canisters,
200        environments: spec.environments.into_keys().collect(),
201        missing_canisters,
202        missing_environments,
203        local_network_present,
204    })
205}
206
207fn read_optional_icp_yaml(path: &Path) -> Result<(String, bool), IcpConfigError> {
208    match fs::read_to_string(path) {
209        Ok(source) => Ok((source, true)),
210        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((String::new(), false)),
211        Err(err) => Err(err.into()),
212    }
213}
214
215fn current_icp_root() -> Result<PathBuf, IcpConfigError> {
216    let start = std::env::current_dir()?;
217    discover_icp_root_from(&start).ok_or(IcpConfigError::NoIcpRoot { start })
218}
219
220/// Resolve the ICP project root implied by the current Canic fleet layout.
221pub fn resolve_current_canic_icp_root() -> Result<PathBuf, IcpConfigError> {
222    let root = current_canic_project_root()?.canonicalize()?;
223    if !discover_project_canic_config_choices(&root)?.is_empty() {
224        return Ok(root);
225    }
226
227    Ok(icp_root()?)
228}
229
230///
231/// CanicIcpSpec
232///
233
234#[derive(Clone, Debug, Eq, PartialEq)]
235struct CanicIcpSpec {
236    canisters: Vec<String>,
237    environments: BTreeMap<String, Vec<String>>,
238}
239
240fn discover_project_spec(
241    root: &Path,
242    fleet_filter: Option<&str>,
243) -> Result<CanicIcpSpec, IcpConfigError> {
244    let choices = discover_project_canic_config_choices(root)?;
245    if choices.is_empty() {
246        return Err(IcpConfigError::Config(format!(
247            "no Canic fleet configs found under {}\nCreate fleets/<fleet>/canic.toml, then add matching entries to icp.yaml and rerun `canic status`.",
248            display_project_fleet_roots(root)
249        )));
250    }
251
252    let mut canisters = Vec::<String>::new();
253    let mut seen_canisters = BTreeSet::<String>::new();
254    let mut environments = BTreeMap::<String, Vec<String>>::new();
255    let mut matched_filter = fleet_filter.is_none();
256
257    for config_path in choices {
258        let fleet = configured_fleet_name(&config_path)?;
259        if let Some(filter) = fleet_filter {
260            if filter != fleet {
261                continue;
262            }
263            matched_filter = true;
264        }
265
266        let roles = configured_deployable_roles(&config_path)?;
267        for role in &roles {
268            if seen_canisters.insert(role.clone()) {
269                canisters.push(role.clone());
270            }
271        }
272        environments.insert(fleet, roles);
273    }
274
275    if let Some(fleet) = fleet_filter
276        && !matched_filter
277    {
278        return Err(IcpConfigError::Config(format!(
279            "no Canic fleet config found for {fleet}\nExpected a config under {} with `[fleet].name = \"{fleet}\"`.",
280            display_project_fleet_roots(root)
281        )));
282    }
283
284    Ok(CanicIcpSpec {
285        canisters,
286        environments,
287    })
288}
289
290fn display_project_fleet_roots(root: &Path) -> String {
291    project_fleet_roots(root)
292        .into_iter()
293        .map(|path| path.display().to_string())
294        .collect::<Vec<_>>()
295        .join(" or ")
296}
297
298fn top_level_section(lines: &[&str], header: &str) -> Option<(usize, usize)> {
299    let start = lines
300        .iter()
301        .position(|line| line_indent(line) == 0 && line.trim() == header)?;
302    let end = lines
303        .iter()
304        .enumerate()
305        .skip(start + 1)
306        .find(|(_, line)| {
307            !line.trim().is_empty() && line_indent(line) == 0 && !line.trim_start().starts_with('#')
308        })
309        .map_or(lines.len(), |(index, _)| index);
310    Some((start, end))
311}
312
313fn resolve_icp_build_environment_from_yaml(
314    source: &str,
315    environment: &str,
316) -> Result<IcpBuildEnvironment, String> {
317    let lines = source.lines().collect::<Vec<_>>();
318    let (_, environment_start, environment_end) =
319        named_item_block(&lines, "environments:", environment)?.ok_or_else(|| {
320            format!(
321                "ICP environment '{environment}' is not declared; add it under environments or use the implicit local/ic environment"
322            )
323        })?;
324    let network = item_scalar_field(&lines, environment_start, environment_end, "network")?
325        .ok_or_else(|| format!("ICP environment '{environment}' has no network"))?;
326
327    match network.as_str() {
328        "ic" => Ok(IcpBuildEnvironment::Ic),
329        "local" => Ok(IcpBuildEnvironment::Local),
330        _ => {
331            let (_, network_start, network_end) = named_item_block(&lines, "networks:", &network)?
332                .ok_or_else(|| {
333                    format!(
334                        "ICP environment '{environment}' references undeclared network '{network}'"
335                    )
336                })?;
337            let mode = item_scalar_field(&lines, network_start, network_end, "mode")?
338                .ok_or_else(|| format!("ICP network '{network}' has no mode"))?;
339            match mode.as_str() {
340                // ICP CLI reserves the implicit `ic` network for mainnet.
341                // Declared managed and connected networks are non-mainnet build classes.
342                "connected" | "managed" => Ok(IcpBuildEnvironment::Local),
343                _ => Err(format!(
344                    "ICP network '{network}' has unsupported mode '{mode}'"
345                )),
346            }
347        }
348    }
349}
350
351fn named_item_block(
352    lines: &[&str],
353    section: &str,
354    name: &str,
355) -> Result<Option<(String, usize, usize)>, String> {
356    let Some((section_start, section_end)) = top_level_section(lines, section) else {
357        return Ok(None);
358    };
359    let starts = lines[section_start + 1..section_end]
360        .iter()
361        .enumerate()
362        .filter_map(|(offset, line)| {
363            if line_indent(line) != 2 {
364                return None;
365            }
366            line.trim()
367                .strip_prefix("- name:")
368                .map(trim_yaml_scalar)
369                .filter(|item_name| !item_name.is_empty())
370                .map(|item_name| (item_name.to_string(), section_start + 1 + offset))
371        })
372        .collect::<Vec<_>>();
373    let matches = starts
374        .iter()
375        .enumerate()
376        .filter(|(_, (item_name, _))| item_name == name)
377        .collect::<Vec<_>>();
378    let [(match_index, (item_name, start))] = matches.as_slice() else {
379        return if matches.is_empty() {
380            Ok(None)
381        } else {
382            Err(format!("duplicate '{name}' entries under {section}"))
383        };
384    };
385    let end = starts
386        .get(match_index + 1)
387        .map_or(section_end, |(_, next_start)| *next_start);
388    Ok(Some((item_name.clone(), *start, end)))
389}
390
391fn item_scalar_field(
392    lines: &[&str],
393    start: usize,
394    end: usize,
395    field: &str,
396) -> Result<Option<String>, String> {
397    let prefix = format!("{field}:");
398    let values = lines[start + 1..end]
399        .iter()
400        .filter_map(|line| {
401            if line_indent(line) != 4 {
402                return None;
403            }
404            line.trim()
405                .strip_prefix(&prefix)
406                .map(trim_yaml_scalar)
407                .filter(|value| !value.is_empty())
408                .map(str::to_string)
409        })
410        .collect::<Vec<_>>();
411    match values.as_slice() {
412        [] => Ok(None),
413        [value] => Ok(Some(value.clone())),
414        _ => Err(format!(
415            "duplicate '{field}' fields in '{}'",
416            section_name(lines, start)
417        )),
418    }
419}
420
421fn section_name<'a>(lines: &'a [&'a str], start: usize) -> &'a str {
422    lines[start]
423        .trim()
424        .strip_prefix("- name:")
425        .map_or("item", trim_yaml_scalar)
426}
427
428fn local_gateway_port_from_yaml(source: &str) -> u16 {
429    let lines = source.lines().collect::<Vec<_>>();
430    let Some((start, end)) = local_network_block(&lines) else {
431        return DEFAULT_LOCAL_GATEWAY_PORT;
432    };
433
434    lines[start..end]
435        .iter()
436        .find_map(|line| {
437            line.trim()
438                .strip_prefix("port:")
439                .and_then(|value| value.trim().parse::<u16>().ok())
440        })
441        .unwrap_or(DEFAULT_LOCAL_GATEWAY_PORT)
442}
443
444fn local_network_block(lines: &[&str]) -> Option<(usize, usize)> {
445    let (section_start, section_end) = top_level_section(lines, "networks:")?;
446    let start = lines[section_start + 1..section_end]
447        .iter()
448        .position(|line| line_indent(line) == 2 && line.trim() == "- name: local")?
449        + section_start
450        + 1;
451    let end = lines[start + 1..section_end]
452        .iter()
453        .position(|line| line_indent(line) == 2 && line.trim_start().starts_with("- name:"))
454        .map_or(section_end, |offset| start + 1 + offset);
455    Some((start, end))
456}
457
458fn top_level_named_items(source: &str, header: &str) -> BTreeSet<String> {
459    let lines = source.lines().collect::<Vec<_>>();
460    let Some((start, end)) = top_level_section(&lines, header) else {
461        return BTreeSet::new();
462    };
463
464    lines[start + 1..end]
465        .iter()
466        .filter_map(|line| {
467            if line_indent(line) != 2 {
468                return None;
469            }
470            line.trim()
471                .strip_prefix("- name:")
472                .map(trim_yaml_scalar)
473                .filter(|name| !name.is_empty())
474                .map(str::to_string)
475        })
476        .collect()
477}
478
479fn trim_yaml_scalar(value: &str) -> &str {
480    value.trim().trim_matches('"').trim_matches('\'')
481}
482
483fn line_indent(line: &str) -> usize {
484    line.chars().take_while(|c| *c == ' ').count()
485}
486
487#[cfg(test)]
488mod tests;