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