1use crate::{
2 install_root::{
3 current_canic_project_root, discover_project_canic_config_choices, project_fleet_roots,
4 },
5 release_set::{configured_fleet_name, configured_fleet_roles, 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#[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#[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
110pub fn configured_local_gateway_port() -> Result<u16, IcpConfigError> {
112 let root = current_icp_root()?;
113 configured_local_gateway_port_from_root(&root)
114}
115
116pub 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
122pub 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
130pub 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
181pub 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#[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_fleet_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 {
367 use super::*;
368 use crate::test_support::temp_dir;
369 use std::fmt::Write as _;
370 use std::fs;
371
372 #[test]
373 fn defaults_local_gateway_port_without_network_config() {
374 let source = "canisters: []\n";
375
376 assert_eq!(
377 local_gateway_port_from_yaml(source),
378 DEFAULT_LOCAL_GATEWAY_PORT
379 );
380 }
381
382 #[test]
383 fn reads_local_gateway_port_from_network_config() {
384 let source = "networks:\n - name: local\n mode: managed\n gateway:\n bind: 127.0.0.1\n port: 8001\n";
385
386 assert_eq!(local_gateway_port_from_yaml(source), 8001);
387 }
388
389 #[test]
390 fn ignores_nested_networks_keys_when_reading_local_gateway_port() {
391 let source = "canisters:\n - name: root\n metadata:\n networks:\n - local\n\nnetworks:\n - name: local\n mode: managed\n gateway:\n bind: 127.0.0.1\n port: 8010\n";
392
393 assert_eq!(local_gateway_port_from_yaml(source), 8010);
394 }
395
396 #[test]
397 fn inspects_icp_yaml_without_mutating_it() {
398 let root = temp_dir("canic-icp-read-only");
399 let config = root.join("fleets/toko/canic.toml");
400 fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
401 fs::write(
402 &config,
403 r#"
404[fleet]
405name = "toko"
406
407[subnets.prime.canisters.root]
408kind = "root"
409
410[subnets.prime.canisters.app]
411kind = "singleton"
412"#,
413 )
414 .expect("write config");
415 let source = r"
416canisters:
417 - name: root
418
419networks:
420 - name: local
421 mode: managed
422 gateway:
423 port: 8010
424
425environments:
426 - name: toko
427 network: local
428 canisters: [root]
429";
430 fs::write(root.join("icp.yaml"), source).expect("write icp yaml");
431
432 let report = inspect_canic_icp_yaml_from_root(&root, Some("toko")).expect("inspect");
433
434 assert_eq!(report.canisters, vec!["root", "app"]);
435 assert_eq!(report.environments, vec!["toko"]);
436 assert_eq!(report.missing_canisters, vec!["app"]);
437 assert!(report.missing_environments.is_empty());
438 assert!(report.local_network_present);
439 assert!(!report.is_ready());
440 assert_eq!(
441 fs::read_to_string(root.join("icp.yaml")).expect("read icp yaml"),
442 source
443 );
444 fs::remove_dir_all(root).expect("clean temp dir");
445 }
446
447 #[test]
448 fn reports_missing_icp_yaml_as_incomplete() {
449 let root = temp_dir("canic-icp-missing-yaml");
450 let config = root.join("fleets/toko/canic.toml");
451 fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
452 fs::write(
453 &config,
454 r#"
455[fleet]
456name = "toko"
457
458[subnets.prime.canisters.root]
459kind = "root"
460"#,
461 )
462 .expect("write config");
463
464 let report = inspect_canic_icp_yaml_from_root(&root, Some("toko")).expect("inspect");
465
466 assert!(!report.icp_yaml_present);
467 assert_eq!(report.missing_canisters, vec!["root"]);
468 assert_eq!(report.missing_environments, vec!["toko"]);
469 assert!(!report.local_network_present);
470 assert!(!report.is_ready());
471 fs::remove_dir_all(root).expect("clean temp dir");
472 }
473
474 #[test]
475 fn discovers_root_fleet_configs_for_icp_inspection() {
476 let root = temp_dir("canic-icp-inspect-root-fleets");
477 let config = root.join("fleets/toko/canic.toml");
478 fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
479 fs::write(
480 &config,
481 r#"
482[fleet]
483name = "toko"
484
485[subnets.prime.canisters.root]
486kind = "root"
487
488[subnets.prime.canisters.app]
489kind = "singleton"
490"#,
491 )
492 .expect("write config");
493
494 let spec = discover_project_spec(&root, Some("toko")).expect("discover spec");
495
496 assert_eq!(spec.canisters, vec!["root", "app"]);
497 assert_eq!(
498 spec.environments,
499 BTreeMap::from([(
500 "toko".to_string(),
501 vec!["root".to_string(), "app".to_string()]
502 )])
503 );
504 fs::remove_dir_all(root).expect("clean temp dir");
505 }
506
507 #[test]
508 fn fleet_filter_limits_inspected_project_spec() {
509 let root = temp_dir("canic-icp-inspect-fleet-filter");
510 write_test_config(
511 &root.join("fleets/demo/canic.toml"),
512 "demo",
513 &["root", "app"],
514 );
515 write_test_config(
516 &root.join("fleets/test/canic.toml"),
517 "test",
518 &["root", "scale"],
519 );
520
521 let spec = discover_project_spec(&root, Some("test")).expect("discover spec");
522
523 assert_eq!(spec.canisters, vec!["root", "scale"]);
524 assert_eq!(
525 spec.environments,
526 BTreeMap::from([(
527 "test".to_string(),
528 vec!["root".to_string(), "scale".to_string()]
529 )])
530 );
531 fs::remove_dir_all(root).expect("clean temp dir");
532 }
533
534 #[test]
535 fn nested_commands_discover_outer_project_root_with_fleets() {
536 let root = temp_dir("canic-icp-root-nested");
537 let config = root.join("fleets/toko/canic.toml");
538 let nested = root.join("backend/src");
539 fs::create_dir_all(&nested).expect("create nested dir");
540 fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
541 fs::write(root.join("icp.yaml"), "").expect("write icp config");
542 fs::write(&config, "[fleet]\nname = \"toko\"\n").expect("write config");
543
544 let icp_root = crate::install_root::discover_canic_project_root_from(&nested)
545 .expect("discover project root")
546 .expect("project root is present");
547
548 assert_eq!(icp_root, root.canonicalize().expect("canonical root"));
549 fs::remove_dir_all(root).expect("clean temp dir");
550 }
551
552 #[test]
553 fn outer_project_root_wins_over_nested_fleets() {
554 let root = temp_dir("canic-icp-root-outer-wins");
555 let outer_config = root.join("fleets/toko/canic.toml");
556 let nested_config = root.join("services/fleets/toko/canic.toml");
557 let nested = root.join("services/src");
558 fs::create_dir_all(outer_config.parent().expect("outer config parent"))
559 .expect("create outer config parent");
560 fs::create_dir_all(nested_config.parent().expect("nested config parent"))
561 .expect("create nested config parent");
562 fs::create_dir_all(&nested).expect("create nested dir");
563 fs::write(root.join("icp.yaml"), "").expect("write icp config");
564 fs::write(&outer_config, "[fleet]\nname = \"toko\"\n").expect("write outer config");
565 fs::write(&nested_config, "[fleet]\nname = \"toko\"\n").expect("write nested config");
566
567 let icp_root = crate::install_root::discover_canic_project_root_from(&nested)
568 .expect("discover project root")
569 .expect("project root is present");
570
571 assert_eq!(icp_root, root.canonicalize().expect("canonical root"));
572 fs::remove_dir_all(root).expect("clean temp dir");
573 }
574
575 #[test]
576 fn icp_inspection_rejects_missing_fleet_configs() {
577 let root = temp_dir("canic-icp-inspect-missing");
578 fs::create_dir_all(&root).expect("create root");
579
580 let err = discover_project_spec(&root, None).expect_err("missing configs should fail");
581 let message = err.to_string();
582
583 assert!(message.contains("no Canic fleet configs found under"));
584 assert!(message.contains("fleets/<fleet>/canic.toml"));
585 fs::remove_dir_all(root).expect("clean temp dir");
586 }
587
588 fn write_test_config(path: &Path, fleet: &str, roles: &[&str]) {
589 fs::create_dir_all(path.parent().expect("config parent")).expect("create config parent");
590 let mut source = format!("[fleet]\nname = \"{fleet}\"\n");
591 for role in roles {
592 let kind = if *role == "root" { "root" } else { "singleton" };
593 write!(
594 source,
595 "\n[subnets.prime.canisters.{role}]\nkind = \"{kind}\"\n"
596 )
597 .expect("write config source");
598 }
599 fs::write(path, source).expect("write config");
600 }
601}