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/// IcpConfigError
20///
21
22#[derive(Debug)]
23pub enum IcpConfigError {
24    NoIcpRoot { start: PathBuf },
25    Config(String),
26    Io(std::io::Error),
27}
28
29impl fmt::Display for IcpConfigError {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::NoIcpRoot { start } => {
33                write!(
34                    formatter,
35                    "could not find icp.yaml from {}",
36                    start.display()
37                )
38            }
39            Self::Config(message) => write!(formatter, "{message}"),
40            Self::Io(err) => write!(formatter, "{err}"),
41        }
42    }
43}
44
45impl Error for IcpConfigError {
46    fn source(&self) -> Option<&(dyn Error + 'static)> {
47        match self {
48            Self::Io(err) => Some(err),
49            Self::Config(_) | Self::NoIcpRoot { .. } => None,
50        }
51    }
52}
53
54impl From<std::io::Error> for IcpConfigError {
55    fn from(err: std::io::Error) -> Self {
56        Self::Io(err)
57    }
58}
59
60///
61/// IcpProjectConfigReport
62///
63
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct IcpProjectConfigReport {
66    pub path: PathBuf,
67    pub icp_root: PathBuf,
68    pub icp_yaml_present: bool,
69    pub canisters: Vec<String>,
70    pub environments: Vec<String>,
71    pub missing_canisters: Vec<String>,
72    pub missing_environments: Vec<String>,
73    pub local_network_present: bool,
74}
75
76impl IcpProjectConfigReport {
77    #[must_use]
78    pub const fn is_ready(&self) -> bool {
79        self.icp_yaml_present
80            && self.local_network_present
81            && self.missing_canisters.is_empty()
82            && self.missing_environments.is_empty()
83    }
84
85    #[must_use]
86    pub fn issues(&self) -> Vec<String> {
87        let mut issues = Vec::new();
88        if !self.icp_yaml_present {
89            issues.push(format!("missing {}", self.path.display()));
90        }
91        if !self.local_network_present {
92            issues.push("missing local network entry".to_string());
93        }
94        if !self.missing_canisters.is_empty() {
95            issues.push(format!(
96                "missing canisters: {}",
97                self.missing_canisters.join(", ")
98            ));
99        }
100        if !self.missing_environments.is_empty() {
101            issues.push(format!(
102                "missing environments: {}",
103                self.missing_environments.join(", ")
104            ));
105        }
106        issues
107    }
108}
109
110/// Return the configured local ICP gateway port, falling back to ICP's default.
111pub(crate) fn configured_local_gateway_port() -> Result<u16, IcpConfigError> {
112    let root = current_icp_root()?;
113    configured_local_gateway_port_from_root(&root)
114}
115
116/// Return the configured local ICP gateway port for one ICP project root.
117pub fn configured_local_gateway_port_from_root(root: &Path) -> Result<u16, IcpConfigError> {
118    let source = fs::read_to_string(root.join(ICP_CONFIG_FILE))?;
119    Ok(local_gateway_port_from_yaml(&source))
120}
121
122/// Inspect whether `icp.yaml` contains the entries implied by Canic fleet configs.
123pub fn inspect_canic_icp_yaml(
124    fleet_filter: Option<&str>,
125) -> Result<IcpProjectConfigReport, IcpConfigError> {
126    let root = resolve_current_canic_icp_root()?;
127    inspect_canic_icp_yaml_from_root(&root, fleet_filter)
128}
129
130/// Inspect one ICP project root without mutating its `icp.yaml`.
131pub fn inspect_canic_icp_yaml_from_root(
132    root: &Path,
133    fleet_filter: Option<&str>,
134) -> Result<IcpProjectConfigReport, IcpConfigError> {
135    let path = root.join(ICP_CONFIG_FILE);
136    let (source, icp_yaml_present) = read_optional_icp_yaml(&path)?;
137    let spec = discover_project_spec(root, fleet_filter)?;
138    let configured_canisters = top_level_named_items(&source, "canisters:");
139    let configured_environments = top_level_named_items(&source, "environments:");
140    let lines = source.lines().collect::<Vec<_>>();
141    let local_network_present = local_network_block(&lines).is_some();
142
143    let missing_canisters = spec
144        .canisters
145        .iter()
146        .filter(|name| !configured_canisters.contains(*name))
147        .cloned()
148        .collect::<Vec<_>>();
149    let missing_environments = spec
150        .environments
151        .keys()
152        .filter(|name| !configured_environments.contains(*name))
153        .cloned()
154        .collect::<Vec<_>>();
155
156    Ok(IcpProjectConfigReport {
157        path,
158        icp_root: root.to_path_buf(),
159        icp_yaml_present,
160        canisters: spec.canisters,
161        environments: spec.environments.into_keys().collect(),
162        missing_canisters,
163        missing_environments,
164        local_network_present,
165    })
166}
167
168fn read_optional_icp_yaml(path: &Path) -> Result<(String, bool), IcpConfigError> {
169    match fs::read_to_string(path) {
170        Ok(source) => Ok((source, true)),
171        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((String::new(), false)),
172        Err(err) => Err(err.into()),
173    }
174}
175
176fn current_icp_root() -> Result<PathBuf, IcpConfigError> {
177    let start = std::env::current_dir()?;
178    discover_icp_root_from(&start).ok_or(IcpConfigError::NoIcpRoot { start })
179}
180
181/// Resolve the ICP project root implied by the current Canic fleet layout.
182pub fn resolve_current_canic_icp_root() -> Result<PathBuf, IcpConfigError> {
183    if let Ok(path) = std::env::var("CANIC_ICP_ROOT") {
184        return PathBuf::from(path)
185            .canonicalize()
186            .map_err(IcpConfigError::from);
187    }
188
189    let search_root = current_project_search_root()?;
190    let choices = discover_project_canic_config_choices(&search_root)
191        .map_err(|err| IcpConfigError::Config(err.to_string()))?;
192    if !choices.is_empty() {
193        return Ok(search_root);
194    }
195
196    current_icp_root().or_else(|_| {
197        icp_root()
198            .map_err(|err| IcpConfigError::Config(err.to_string()))
199            .and_then(|path| path.canonicalize().map_err(IcpConfigError::from))
200    })
201}
202
203fn current_project_search_root() -> Result<PathBuf, IcpConfigError> {
204    let root = current_canic_project_root()
205        .map_err(|err| IcpConfigError::Config(err.to_string()))?
206        .canonicalize()?;
207    if !discover_project_canic_config_choices(&root)
208        .map_err(|err| IcpConfigError::Config(err.to_string()))?
209        .is_empty()
210    {
211        return Ok(root);
212    }
213
214    if let Ok(root) = icp_root() {
215        return Ok(root);
216    }
217    Ok(std::env::current_dir()?.canonicalize()?)
218}
219
220///
221/// CanicIcpSpec
222///
223
224#[derive(Clone, Debug, Eq, PartialEq)]
225struct CanicIcpSpec {
226    canisters: Vec<String>,
227    environments: BTreeMap<String, Vec<String>>,
228}
229
230fn discover_project_spec(
231    root: &Path,
232    fleet_filter: Option<&str>,
233) -> Result<CanicIcpSpec, IcpConfigError> {
234    let choices = discover_project_canic_config_choices(root)
235        .map_err(|err| IcpConfigError::Config(err.to_string()))?;
236    if choices.is_empty() {
237        return Err(IcpConfigError::Config(format!(
238            "no Canic fleet configs found under {}\nCreate fleets/<fleet>/canic.toml, then add matching entries to icp.yaml and rerun `canic status`.",
239            display_project_fleet_roots(root)
240        )));
241    }
242
243    let mut canisters = Vec::<String>::new();
244    let mut seen_canisters = BTreeSet::<String>::new();
245    let mut environments = BTreeMap::<String, Vec<String>>::new();
246    let mut matched_filter = fleet_filter.is_none();
247
248    for config_path in choices {
249        let fleet = configured_fleet_name(&config_path)
250            .map_err(|err| IcpConfigError::Config(err.to_string()))?;
251        if let Some(filter) = fleet_filter {
252            if filter != fleet {
253                continue;
254            }
255            matched_filter = true;
256        }
257
258        let roles = configured_deployable_roles(&config_path)
259            .map_err(|err| IcpConfigError::Config(err.to_string()))?;
260        for role in &roles {
261            if seen_canisters.insert(role.clone()) {
262                canisters.push(role.clone());
263            }
264        }
265        environments.insert(fleet, roles);
266    }
267
268    if let Some(fleet) = fleet_filter
269        && !matched_filter
270    {
271        return Err(IcpConfigError::Config(format!(
272            "no Canic fleet config found for {fleet}\nExpected a config under {} with `[fleet].name = \"{fleet}\"`.",
273            display_project_fleet_roots(root)
274        )));
275    }
276
277    Ok(CanicIcpSpec {
278        canisters,
279        environments,
280    })
281}
282
283fn display_project_fleet_roots(root: &Path) -> String {
284    project_fleet_roots(root)
285        .into_iter()
286        .map(|path| path.display().to_string())
287        .collect::<Vec<_>>()
288        .join(" or ")
289}
290
291fn top_level_section(lines: &[&str], header: &str) -> Option<(usize, usize)> {
292    let start = lines
293        .iter()
294        .position(|line| line_indent(line) == 0 && line.trim() == header)?;
295    let end = lines
296        .iter()
297        .enumerate()
298        .skip(start + 1)
299        .find(|(_, line)| {
300            !line.trim().is_empty() && line_indent(line) == 0 && !line.trim_start().starts_with('#')
301        })
302        .map_or(lines.len(), |(index, _)| index);
303    Some((start, end))
304}
305
306fn local_gateway_port_from_yaml(source: &str) -> u16 {
307    let lines = source.lines().collect::<Vec<_>>();
308    let Some((start, end)) = local_network_block(&lines) else {
309        return DEFAULT_LOCAL_GATEWAY_PORT;
310    };
311
312    lines[start..end]
313        .iter()
314        .find_map(|line| {
315            line.trim()
316                .strip_prefix("port:")
317                .and_then(|value| value.trim().parse::<u16>().ok())
318        })
319        .unwrap_or(DEFAULT_LOCAL_GATEWAY_PORT)
320}
321
322fn local_network_block(lines: &[&str]) -> Option<(usize, usize)> {
323    let (section_start, section_end) = top_level_section(lines, "networks:")?;
324    let start = lines[section_start + 1..section_end]
325        .iter()
326        .position(|line| line_indent(line) == 2 && line.trim() == "- name: local")?
327        + section_start
328        + 1;
329    let end = lines[start + 1..section_end]
330        .iter()
331        .position(|line| line_indent(line) == 2 && line.trim_start().starts_with("- name:"))
332        .map_or(section_end, |offset| start + 1 + offset);
333    Some((start, end))
334}
335
336fn top_level_named_items(source: &str, header: &str) -> BTreeSet<String> {
337    let lines = source.lines().collect::<Vec<_>>();
338    let Some((start, end)) = top_level_section(&lines, header) else {
339        return BTreeSet::new();
340    };
341
342    lines[start + 1..end]
343        .iter()
344        .filter_map(|line| {
345            if line_indent(line) != 2 {
346                return None;
347            }
348            line.trim()
349                .strip_prefix("- name:")
350                .map(trim_yaml_scalar)
351                .filter(|name| !name.is_empty())
352                .map(str::to_string)
353        })
354        .collect()
355}
356
357fn trim_yaml_scalar(value: &str) -> &str {
358    value.trim().trim_matches('"').trim_matches('\'')
359}
360
361fn line_indent(line: &str) -> usize {
362    line.chars().take_while(|c| *c == ' ').count()
363}
364
365#[cfg(test)]
366mod tests;