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