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#[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#[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#[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
119pub(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
125pub 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
131pub 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
167pub 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
175pub 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
226pub 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#[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 "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;