Skip to main content

canic_host/icp_config/
mod.rs

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