Skip to main content

canic_host/icp_config/
mod.rs

1//! Local ICP configuration inspection and Canic App membership validation.
2//!
3//! Owns the inline config projection; ICP owns build/recipe execution and network state.
4
5mod manifest;
6
7#[cfg(test)]
8mod tests;
9
10use crate::{
11    config_discovery::{
12        ConfigDiscoveryError, current_canic_workspace_root,
13        discover_workspace_canic_config_choices, workspace_app_roots,
14    },
15    icp_config::manifest::IcpManifest,
16    release_set::{AppConfigError, AppConfigSnapshot, WorkspaceDiscoveryError, icp_root},
17    workspace_discovery::discover_icp_root_from,
18};
19use canic_core::ids::BuildNetwork;
20use std::{
21    collections::{BTreeMap, BTreeSet},
22    fs,
23    io::Read as _,
24    path::{Path, PathBuf},
25};
26use thiserror::Error as ThisError;
27
28pub use manifest::IcpManifestError;
29
30const ICP_CONFIG_FILE: &str = "icp.yaml";
31pub const DEFAULT_LOCAL_GATEWAY_PORT: u16 = 8000;
32
33///
34/// IcpConfigError
35///
36
37#[derive(Debug, ThisError)]
38pub enum IcpConfigError {
39    #[error("{}: {source}", path.display())]
40    Manifest {
41        path: PathBuf,
42        source: IcpManifestError,
43    },
44    #[error("could not find icp.yaml from {}", start.display())]
45    NoIcpRoot { start: PathBuf },
46
47    #[error("{0}")]
48    Config(String),
49
50    #[error(transparent)]
51    ConfigDiscovery(#[from] ConfigDiscoveryError),
52
53    #[error(transparent)]
54    AppConfig(#[from] AppConfigError),
55
56    #[error(transparent)]
57    WorkspaceDiscovery(#[from] WorkspaceDiscoveryError),
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    /// Required App roles excluded by each declared environment's selection.
77    pub missing_environment_canisters: BTreeMap<String, Vec<String>>,
78    pub local_network_present: bool,
79}
80
81impl IcpProjectConfigReport {
82    #[must_use]
83    pub fn is_ready(&self) -> bool {
84        self.icp_yaml_present
85            && self.local_network_present
86            && self.missing_canisters.is_empty()
87            && self.missing_environments.is_empty()
88            && self.missing_environment_canisters.is_empty()
89    }
90
91    #[must_use]
92    pub fn issues(&self) -> Vec<String> {
93        let mut issues = Vec::new();
94        if !self.icp_yaml_present {
95            issues.push(format!("missing {}", self.path.display()));
96        }
97        if !self.local_network_present {
98            issues.push("missing local network entry".to_string());
99        }
100        if !self.missing_canisters.is_empty() {
101            issues.push(format!(
102                "missing canisters: {}",
103                self.missing_canisters.join(", ")
104            ));
105        }
106        if !self.missing_environments.is_empty() {
107            issues.push(format!(
108                "missing environments: {}",
109                self.missing_environments.join(", ")
110            ));
111        }
112        for (environment, canisters) in &self.missing_environment_canisters {
113            issues.push(format!(
114                "environment {environment} excludes required canisters: {}",
115                canisters.join(", ")
116            ));
117        }
118        issues
119    }
120}
121
122/// Return the configured local ICP gateway port, falling back to ICP's default.
123pub(crate) fn configured_local_gateway_port() -> Result<u16, IcpConfigError> {
124    let root = current_icp_root()?;
125    configured_local_gateway_port_from_root(&root)
126}
127
128/// Return the configured local ICP gateway port for one ICP project root.
129pub fn configured_local_gateway_port_from_root(root: &Path) -> Result<u16, IcpConfigError> {
130    let path = root.join(ICP_CONFIG_FILE);
131    let (source, _) = read_optional_icp_yaml(&path)?;
132    local_gateway_port_from_yaml(&source)
133        .map_err(|source| IcpConfigError::Manifest { path, source })
134}
135
136/// Resolve a selected ICP environment to the build-time network class used by Canic.
137///
138/// Explicit environment declarations take precedence over implicit local/ic defaults.
139/// Canic reads inline network identity without resolving recipes or dependencies.
140pub fn resolve_icp_build_network_from_root(
141    root: &Path,
142    environment: &str,
143) -> Result<BuildNetwork, IcpConfigError> {
144    let path = root.join(ICP_CONFIG_FILE);
145    let (source, _) = read_optional_icp_yaml(&path)?;
146    resolve_icp_build_network_from_yaml(&source, environment)
147        .map_err(|source| IcpConfigError::Manifest { path, source })
148}
149
150/// Inspect whether `icp.yaml` contains the entries implied by Canic App configs.
151pub fn inspect_canic_icp_yaml(
152    app_filter: Option<&str>,
153) -> Result<IcpProjectConfigReport, IcpConfigError> {
154    let root = resolve_current_canic_icp_root()?;
155    inspect_canic_icp_yaml_from_root(&root, app_filter)
156}
157
158/// Inspect one ICP project root without mutating its `icp.yaml`.
159pub fn inspect_canic_icp_yaml_from_root(
160    root: &Path,
161    app_filter: Option<&str>,
162) -> Result<IcpProjectConfigReport, IcpConfigError> {
163    let path = root.join(ICP_CONFIG_FILE);
164    let (source, icp_yaml_present) = read_optional_icp_yaml(&path)?;
165    let spec = discover_project_spec(root, app_filter)?;
166    let manifest = IcpManifest::parse(&source).map_err(|source| IcpConfigError::Manifest {
167        path: path.clone(),
168        source,
169    })?;
170    let local_network_present = icp_yaml_present;
171
172    let missing_canisters = spec
173        .canisters
174        .iter()
175        .filter(|name| !manifest.has_canister(name))
176        .cloned()
177        .collect::<Vec<_>>();
178    let missing_environments = spec
179        .environments
180        .keys()
181        .filter(|name| !manifest.has_environment(name))
182        .cloned()
183        .collect::<Vec<_>>();
184
185    let missing_environment_canisters = spec
186        .environments
187        .iter()
188        .filter(|(environment, _)| manifest.has_environment(environment))
189        .filter_map(|(environment, roles)| {
190            let missing = roles
191                .iter()
192                .filter(|role| !manifest.contains(environment, role))
193                .cloned()
194                .collect::<Vec<_>>();
195            (!missing.is_empty()).then(|| (environment.clone(), missing))
196        })
197        .collect();
198
199    Ok(IcpProjectConfigReport {
200        path,
201        icp_root: root.to_path_buf(),
202        icp_yaml_present,
203        canisters: spec.canisters,
204        environments: spec.environments.into_keys().collect(),
205        missing_canisters,
206        missing_environments,
207        missing_environment_canisters,
208        local_network_present,
209    })
210}
211
212fn read_optional_icp_yaml(path: &Path) -> Result<(String, bool), IcpConfigError> {
213    match fs::File::open(path) {
214        Ok(file) => {
215            let mut source = String::new();
216            file.take(1024 * 1024 + 1).read_to_string(&mut source)?;
217            Ok((source, true))
218        }
219        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok((String::new(), false)),
220        Err(err) => Err(err.into()),
221    }
222}
223
224fn current_icp_root() -> Result<PathBuf, IcpConfigError> {
225    let start = std::env::current_dir().map_err(WorkspaceDiscoveryError::CurrentDirectory)?;
226    discover_icp_root_from(&start)?.ok_or(IcpConfigError::NoIcpRoot { start })
227}
228
229/// Resolve the ICP project root implied by the current Canic app layout.
230pub fn resolve_current_canic_icp_root() -> Result<PathBuf, IcpConfigError> {
231    let root = current_canic_workspace_root()?.canonicalize()?;
232    if !discover_workspace_canic_config_choices(&root)?.is_empty() {
233        return Ok(root);
234    }
235
236    Ok(icp_root()?)
237}
238
239///
240/// CanicIcpSpec
241///
242
243#[derive(Clone, Debug, Eq, PartialEq)]
244struct CanicIcpSpec {
245    canisters: Vec<String>,
246    environments: BTreeMap<String, Vec<String>>,
247}
248
249fn discover_project_spec(
250    root: &Path,
251    app_filter: Option<&str>,
252) -> Result<CanicIcpSpec, IcpConfigError> {
253    let choices = discover_workspace_canic_config_choices(root)?;
254    if choices.is_empty() {
255        return Err(IcpConfigError::Config(format!(
256            "no Canic App configs found under {}\nCreate apps/<app>/canic.toml, add matching entries to icp.yaml, then retry the requested command.",
257            display_workspace_app_roots(root)
258        )));
259    }
260
261    let mut canisters = Vec::<String>::new();
262    let mut seen_canisters = BTreeSet::<String>::new();
263    let mut environments = BTreeMap::<String, Vec<String>>::new();
264    let mut matched_filter = app_filter.is_none();
265
266    for config_path in choices {
267        let config = AppConfigSnapshot::load(&config_path)?;
268        let app = config.app_id().to_string();
269        if let Some(filter) = app_filter {
270            if filter != app {
271                continue;
272            }
273            matched_filter = true;
274        }
275
276        let roles = config.deployable_roles();
277        for role in &roles {
278            if seen_canisters.insert(role.clone()) {
279                canisters.push(role.clone());
280            }
281        }
282        environments.insert(app, roles);
283    }
284
285    if let Some(app) = app_filter
286        && !matched_filter
287    {
288        return Err(IcpConfigError::Config(format!(
289            "no Canic App config found for {app}\nExpected a config under {} with `[app].name = \"{app}\"`.",
290            display_workspace_app_roots(root)
291        )));
292    }
293
294    Ok(CanicIcpSpec {
295        canisters,
296        environments,
297    })
298}
299
300fn display_workspace_app_roots(root: &Path) -> String {
301    workspace_app_roots(root)
302        .into_iter()
303        .map(|path| path.display().to_string())
304        .collect::<Vec<_>>()
305        .join(" or ")
306}
307
308fn resolve_icp_build_network_from_yaml(
309    source: &str,
310    environment: &str,
311) -> Result<BuildNetwork, IcpManifestError> {
312    IcpManifest::parse(source)?.build_network(environment)
313}
314
315fn local_gateway_port_from_yaml(source: &str) -> Result<u16, IcpManifestError> {
316    IcpManifest::parse(source)?.local_gateway_port()
317}