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 io::ErrorKind,
13 path::{Path, PathBuf},
14};
15
16const ICP_CONFIG_FILE: &str = "icp.yaml";
17pub const DEFAULT_LOCAL_GATEWAY_PORT: u16 = 8000;
18
19#[derive(Debug)]
24pub enum IcpConfigError {
25 NoIcpRoot { start: PathBuf },
26 Config(String),
27 Io(std::io::Error),
28}
29
30impl fmt::Display for IcpConfigError {
31 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32 match self {
33 Self::NoIcpRoot { start } => {
34 write!(
35 formatter,
36 "could not find icp.yaml from {}",
37 start.display()
38 )
39 }
40 Self::Config(message) => write!(formatter, "{message}"),
41 Self::Io(err) => write!(formatter, "{err}"),
42 }
43 }
44}
45
46impl Error for IcpConfigError {
47 fn source(&self) -> Option<&(dyn Error + 'static)> {
48 match self {
49 Self::Io(err) => Some(err),
50 Self::Config(_) | Self::NoIcpRoot { .. } => None,
51 }
52 }
53}
54
55impl From<std::io::Error> for IcpConfigError {
56 fn from(err: std::io::Error) -> Self {
57 Self::Io(err)
58 }
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct IcpProjectSyncReport {
67 pub path: PathBuf,
68 pub icp_root: PathBuf,
69 pub changed: bool,
70 pub canisters: Vec<String>,
71 pub environments: Vec<String>,
72}
73
74pub fn configured_local_gateway_port() -> Result<u16, IcpConfigError> {
76 let root = current_icp_root()?;
77 configured_local_gateway_port_from_root(&root)
78}
79
80pub fn configured_local_gateway_port_from_root(root: &Path) -> Result<u16, IcpConfigError> {
82 let source = fs::read_to_string(root.join(ICP_CONFIG_FILE))?;
83 Ok(configured_local_gateway_port_from_source(&source))
84}
85
86pub fn set_configured_local_gateway_port(port: u16) -> Result<PathBuf, IcpConfigError> {
88 let root = current_icp_root()?;
89 set_configured_local_gateway_port_in_root(&root, port)
90}
91
92pub fn set_configured_local_gateway_port_in_root(
94 root: &Path,
95 port: u16,
96) -> Result<PathBuf, IcpConfigError> {
97 let path = root.join(ICP_CONFIG_FILE);
98 let source = fs::read_to_string(&path)?;
99 let updated = upsert_local_gateway_port(&source, port);
100 fs::write(&path, updated)?;
101 Ok(path)
102}
103
104pub fn sync_canic_icp_yaml(
106 fleet_filter: Option<&str>,
107) -> Result<IcpProjectSyncReport, IcpConfigError> {
108 let root = resolve_current_canic_icp_root()?;
109 let path = root.join(ICP_CONFIG_FILE);
110 let source = match fs::read_to_string(&path) {
111 Ok(source) => source,
112 Err(err) if err.kind() == ErrorKind::NotFound => String::new(),
113 Err(err) => return Err(err.into()),
114 };
115 let spec = discover_project_spec(&root, fleet_filter)?;
116 let updated = sync_canic_sections(&source, &spec.canisters, &spec.environments);
117 let changed = updated != source;
118 if changed {
119 fs::write(&path, updated)?;
120 }
121
122 Ok(IcpProjectSyncReport {
123 path,
124 icp_root: root,
125 changed,
126 canisters: spec.canisters,
127 environments: spec.environments.into_keys().collect(),
128 })
129}
130
131fn current_icp_root() -> Result<PathBuf, IcpConfigError> {
132 let start = std::env::current_dir()?;
133 discover_icp_root_from(&start).ok_or(IcpConfigError::NoIcpRoot { start })
134}
135
136pub fn resolve_current_canic_icp_root() -> Result<PathBuf, IcpConfigError> {
138 if let Ok(path) = std::env::var("CANIC_ICP_ROOT") {
139 return PathBuf::from(path)
140 .canonicalize()
141 .map_err(IcpConfigError::from);
142 }
143
144 let search_root = current_project_search_root()?;
145 let choices = discover_project_canic_config_choices(&search_root)
146 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
147 if !choices.is_empty() {
148 return Ok(search_root);
149 }
150
151 current_icp_root().or_else(|_| {
152 icp_root()
153 .map_err(|err| IcpConfigError::Config(err.to_string()))
154 .and_then(|path| path.canonicalize().map_err(IcpConfigError::from))
155 })
156}
157
158fn current_project_search_root() -> Result<PathBuf, IcpConfigError> {
159 let root = current_canic_project_root()
160 .map_err(|err| IcpConfigError::Config(err.to_string()))?
161 .canonicalize()?;
162 if !discover_project_canic_config_choices(&root)
163 .map_err(|err| IcpConfigError::Config(err.to_string()))?
164 .is_empty()
165 {
166 return Ok(root);
167 }
168
169 if let Ok(root) = icp_root() {
170 return Ok(root);
171 }
172 Ok(std::env::current_dir()?.canonicalize()?)
173}
174
175#[derive(Clone, Debug, Eq, PartialEq)]
180struct CanicIcpSpec {
181 canisters: Vec<String>,
182 environments: BTreeMap<String, Vec<String>>,
183}
184
185fn discover_project_spec(
186 root: &Path,
187 fleet_filter: Option<&str>,
188) -> Result<CanicIcpSpec, IcpConfigError> {
189 let choices = discover_project_canic_config_choices(root)
190 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
191 if choices.is_empty() {
192 return Err(IcpConfigError::Config(format!(
193 "no Canic fleet configs found under {}\nCreate fleets/<fleet>/canic.toml, then rerun `canic replica start` or `canic fleet sync --fleet <fleet>`.",
194 display_project_fleet_roots(root)
195 )));
196 }
197
198 let mut canisters = Vec::<String>::new();
199 let mut seen_canisters = BTreeSet::<String>::new();
200 let mut environments = BTreeMap::<String, Vec<String>>::new();
201 let mut matched_filter = fleet_filter.is_none();
202
203 for config_path in choices {
204 let fleet = configured_fleet_name(&config_path)
205 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
206 if fleet_filter.is_some_and(|filter| filter == fleet) {
207 matched_filter = true;
208 }
209
210 let roles = configured_fleet_roles(&config_path)
211 .map_err(|err| IcpConfigError::Config(err.to_string()))?;
212 for role in &roles {
213 if seen_canisters.insert(role.clone()) {
214 canisters.push(role.clone());
215 }
216 }
217 environments.insert(fleet, roles);
218 }
219
220 if let Some(fleet) = fleet_filter
221 && !matched_filter
222 {
223 return Err(IcpConfigError::Config(format!(
224 "no Canic fleet config found for {fleet}\nExpected a config under {} with `[fleet].name = \"{fleet}\"`.",
225 display_project_fleet_roots(root)
226 )));
227 }
228
229 Ok(CanicIcpSpec {
230 canisters,
231 environments,
232 })
233}
234
235fn display_project_fleet_roots(root: &Path) -> String {
236 project_fleet_roots(root)
237 .into_iter()
238 .map(|path| path.display().to_string())
239 .collect::<Vec<_>>()
240 .join(" or ")
241}
242
243fn sync_canic_sections(
244 source: &str,
245 canisters: &[String],
246 environments: &BTreeMap<String, Vec<String>>,
247) -> String {
248 let without_canisters = remove_top_level_section(source, "canisters:");
249 let rest = remove_top_level_section(&without_canisters, "environments:");
250 let mut sections = vec![
251 render_canisters_section(canisters),
252 render_environments_section(environments),
253 ];
254 let rest = rest.trim();
255 if !rest.is_empty() {
256 sections.push(rest.to_string());
257 }
258
259 let mut updated = sections.join("\n\n");
260 updated.push('\n');
261 updated
262}
263
264fn render_canisters_section(canisters: &[String]) -> String {
265 if canisters.is_empty() {
266 return "canisters: []".to_string();
267 }
268
269 let mut lines = vec!["canisters:".to_string()];
270 for (index, canister) in canisters.iter().enumerate() {
271 if index > 0 {
272 lines.push(String::new());
273 }
274 lines.extend([
275 format!(" - name: {canister}"),
276 " build:".to_string(),
277 " steps:".to_string(),
278 " - type: script".to_string(),
279 " commands:".to_string(),
280 format!(
281 " - cargo run -q -p canic-host --example build_artifact -- {canister}"
282 ),
283 ]);
284 }
285 lines.join("\n")
286}
287
288fn render_environments_section(environments: &BTreeMap<String, Vec<String>>) -> String {
289 if environments.is_empty() {
290 return "environments: []".to_string();
291 }
292
293 environments
294 .iter()
295 .enumerate()
296 .flat_map(|(index, (environment, canisters))| {
297 let mut lines = Vec::new();
298 if index > 0 {
299 lines.push(String::new());
300 }
301 if index == 0 {
302 lines.push("environments:".to_string());
303 }
304 lines.extend([
305 format!(" - name: {environment}"),
306 " network: local".to_string(),
307 format!(" canisters: [{}]", canisters.join(", ")),
308 ]);
309 lines
310 })
311 .collect::<Vec<_>>()
312 .join("\n")
313}
314
315fn remove_top_level_section(source: &str, header: &str) -> String {
316 let mut lines = source.lines().map(str::to_string).collect::<Vec<_>>();
317 let line_refs = lines.iter().map(String::as_str).collect::<Vec<_>>();
318 let Some((start, end)) = top_level_section(&line_refs, header) else {
319 return source.to_string();
320 };
321 lines.drain(start..end);
322
323 let mut compacted = Vec::<String>::new();
324 let mut previous_blank = false;
325 for line in lines {
326 let blank = line.trim().is_empty();
327 if blank && previous_blank {
328 continue;
329 }
330 compacted.push(line);
331 previous_blank = blank;
332 }
333
334 compacted.join("\n")
335}
336
337fn top_level_section(lines: &[&str], header: &str) -> Option<(usize, usize)> {
338 let start = lines
339 .iter()
340 .position(|line| line_indent(line) == 0 && line.trim() == header)?;
341 let end = lines
342 .iter()
343 .enumerate()
344 .skip(start + 1)
345 .find(|(_, line)| {
346 !line.trim().is_empty() && line_indent(line) == 0 && !line.trim_start().starts_with('#')
347 })
348 .map_or(lines.len(), |(index, _)| index);
349 Some((start, end))
350}
351
352fn configured_local_gateway_port_from_source(source: &str) -> u16 {
353 let lines = source.lines().collect::<Vec<_>>();
354 let Some((start, end)) = local_network_block(&lines) else {
355 return DEFAULT_LOCAL_GATEWAY_PORT;
356 };
357
358 lines[start..end]
359 .iter()
360 .find_map(|line| {
361 line.trim()
362 .strip_prefix("port:")
363 .and_then(|value| value.trim().parse::<u16>().ok())
364 })
365 .unwrap_or(DEFAULT_LOCAL_GATEWAY_PORT)
366}
367
368fn upsert_local_gateway_port(source: &str, port: u16) -> String {
369 let had_trailing_newline = source.ends_with('\n');
370 let mut lines = source.lines().map(str::to_string).collect::<Vec<_>>();
371
372 let local_block = {
373 let line_refs = lines.iter().map(String::as_str).collect::<Vec<_>>();
374 local_network_block(&line_refs)
375 };
376 if let Some((start, end)) = local_block {
377 if let Some(index) = (start..end).find(|index| lines[*index].trim().starts_with("port:")) {
378 let indent = line_indent(&lines[index]);
379 lines[index] = format!("{}port: {port}", " ".repeat(indent));
380 return join_lines(lines, had_trailing_newline);
381 }
382
383 if let Some(gateway_index) = (start..end).find(|index| lines[*index].trim() == "gateway:") {
384 let indent = line_indent(&lines[gateway_index]) + 2;
385 lines.insert(
386 gateway_index + 1,
387 format!("{}port: {port}", " ".repeat(indent)),
388 );
389 return join_lines(lines, had_trailing_newline);
390 }
391
392 lines.splice(end..end, local_network_gateway_lines(port));
393 return join_lines(lines, had_trailing_newline);
394 }
395
396 let networks = {
397 let line_refs = lines.iter().map(String::as_str).collect::<Vec<_>>();
398 networks_section(&line_refs)
399 };
400 if let Some((networks_start, networks_end)) = networks {
401 let local_network = local_network_lines(port);
402 let inserted_len = local_network.len();
403 lines.splice(networks_end..networks_end, local_network);
404 if networks_end == networks_start + 1 {
405 lines.insert(networks_end + inserted_len, String::new());
406 }
407 return join_lines(lines, had_trailing_newline);
408 }
409
410 let insert_at = lines
411 .iter()
412 .position(|line| line.trim() == "environments:")
413 .unwrap_or(lines.len());
414 let mut insert = vec!["networks:".to_string()];
415 insert.extend(local_network_lines(port));
416 insert.push(String::new());
417 lines.splice(insert_at..insert_at, insert);
418 join_lines(lines, had_trailing_newline)
419}
420
421fn networks_section(lines: &[&str]) -> Option<(usize, usize)> {
422 let start = lines.iter().position(|line| line.trim() == "networks:")?;
423 let end = lines
424 .iter()
425 .enumerate()
426 .skip(start + 1)
427 .find(|(_, line)| {
428 !line.trim().is_empty() && line_indent(line) == 0 && !line.trim_start().starts_with('#')
429 })
430 .map_or(lines.len(), |(index, _)| index);
431 Some((start, end))
432}
433
434fn local_network_block(lines: &[&str]) -> Option<(usize, usize)> {
435 let (section_start, section_end) = networks_section(lines)?;
436 let start = lines[section_start + 1..section_end]
437 .iter()
438 .position(|line| line_indent(line) == 2 && line.trim() == "- name: local")?
439 + section_start
440 + 1;
441 let end = lines[start + 1..section_end]
442 .iter()
443 .position(|line| line_indent(line) == 2 && line.trim_start().starts_with("- name:"))
444 .map_or(section_end, |offset| start + 1 + offset);
445 Some((start, end))
446}
447
448fn local_network_lines(port: u16) -> Vec<String> {
449 vec![
450 " - name: local".to_string(),
451 " mode: managed".to_string(),
452 " gateway:".to_string(),
453 " bind: 127.0.0.1".to_string(),
454 format!(" port: {port}"),
455 ]
456}
457
458fn local_network_gateway_lines(port: u16) -> Vec<String> {
459 vec![
460 " gateway:".to_string(),
461 " bind: 127.0.0.1".to_string(),
462 format!(" port: {port}"),
463 ]
464}
465
466fn line_indent(line: &str) -> usize {
467 line.chars().take_while(|c| *c == ' ').count()
468}
469
470fn join_lines(lines: Vec<String>, had_trailing_newline: bool) -> String {
471 let mut joined = lines.join("\n");
472 if had_trailing_newline {
473 joined.push('\n');
474 }
475 joined
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::test_support::temp_dir;
482 use std::fs;
483
484 #[test]
485 fn defaults_local_gateway_port_without_network_config() {
486 let source = "canisters: []\n";
487
488 assert_eq!(
489 configured_local_gateway_port_from_source(source),
490 DEFAULT_LOCAL_GATEWAY_PORT
491 );
492 }
493
494 #[test]
495 fn reads_local_gateway_port_from_network_config() {
496 let source = "networks:\n - name: local\n mode: managed\n gateway:\n bind: 127.0.0.1\n port: 8001\n";
497
498 assert_eq!(configured_local_gateway_port_from_source(source), 8001);
499 }
500
501 #[test]
502 fn inserts_local_network_before_environments() {
503 let source = "canisters: []\n\nenvironments:\n - name: local\n network: local\n";
504
505 let updated = upsert_local_gateway_port(source, 8002);
506
507 assert!(updated.contains("networks:\n - name: local\n mode: managed"));
508 assert!(updated.contains(" port: 8002"));
509 assert!(updated.find("networks:") < updated.find("environments:"));
510 }
511
512 #[test]
513 fn replaces_existing_local_gateway_port() {
514 let source = "networks:\n - name: local\n mode: managed\n gateway:\n bind: 127.0.0.1\n port: 8001\n";
515
516 let updated = upsert_local_gateway_port(source, 8003);
517
518 assert!(updated.contains(" port: 8003"));
519 assert!(!updated.contains(" port: 8001"));
520 }
521
522 #[test]
523 fn syncs_canic_sections_and_preserves_other_top_level_sections() {
524 let source = "canisters:\n - name: old\n\nnetworks:\n - name: local\n mode: managed\n gateway:\n bind: 127.0.0.1\n port: 8009\n\nenvironments:\n - name: old\n network: local\n canisters: [old]\n";
525 let canisters = vec!["root".to_string(), "app".to_string()];
526 let environments = BTreeMap::from([(
527 "test".to_string(),
528 vec!["root".to_string(), "app".to_string()],
529 )]);
530
531 let updated = sync_canic_sections(source, &canisters, &environments);
532
533 assert!(updated.starts_with("canisters:\n - name: root\n"));
534 assert!(
535 updated.contains(
536 " - cargo run -q -p canic-host --example build_artifact -- app"
537 )
538 );
539 assert!(updated.contains(
540 "environments:\n - name: test\n network: local\n canisters: [root, app]"
541 ));
542 assert!(updated.contains("networks:\n - name: local\n mode: managed"));
543 assert!(!updated.contains("- name: old"));
544 }
545
546 #[test]
547 fn renders_empty_canic_sections_for_empty_project_specs() {
548 let updated = sync_canic_sections("", &[], &BTreeMap::new());
549
550 assert_eq!(updated, "canisters: []\n\nenvironments: []\n");
551 }
552
553 #[test]
554 fn discovers_root_fleet_configs_for_icp_sync() {
555 let root = temp_dir("canic-icp-sync-root-fleets");
556 let config = root.join("fleets/toko/canic.toml");
557 fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
558 fs::write(
559 &config,
560 r#"
561[fleet]
562name = "toko"
563
564[subnets.prime.canisters.root]
565kind = "root"
566
567[subnets.prime.canisters.app]
568kind = "singleton"
569"#,
570 )
571 .expect("write config");
572
573 let spec = discover_project_spec(&root, Some("toko")).expect("discover spec");
574
575 assert_eq!(spec.canisters, vec!["root", "app"]);
576 assert_eq!(
577 spec.environments,
578 BTreeMap::from([(
579 "toko".to_string(),
580 vec!["root".to_string(), "app".to_string()]
581 )])
582 );
583 fs::remove_dir_all(root).expect("clean temp dir");
584 }
585
586 #[test]
587 fn nested_commands_discover_outer_project_root_with_fleets() {
588 let root = temp_dir("canic-icp-root-nested");
589 let config = root.join("fleets/toko/canic.toml");
590 let nested = root.join("backend/src");
591 fs::create_dir_all(&nested).expect("create nested dir");
592 fs::create_dir_all(config.parent().expect("config parent")).expect("create config parent");
593 fs::write(root.join("icp.yaml"), "").expect("write icp config");
594 fs::write(&config, "[fleet]\nname = \"toko\"\n").expect("write config");
595
596 let icp_root = crate::install_root::discover_canic_project_root_from(&nested)
597 .expect("discover project root")
598 .expect("project root is present");
599
600 assert_eq!(icp_root, root.canonicalize().expect("canonical root"));
601 fs::remove_dir_all(root).expect("clean temp dir");
602 }
603
604 #[test]
605 fn outer_project_root_wins_over_nested_fleets() {
606 let root = temp_dir("canic-icp-root-outer-wins");
607 let outer_config = root.join("fleets/toko/canic.toml");
608 let nested_config = root.join("services/fleets/toko/canic.toml");
609 let nested = root.join("services/src");
610 fs::create_dir_all(outer_config.parent().expect("outer config parent"))
611 .expect("create outer config parent");
612 fs::create_dir_all(nested_config.parent().expect("nested config parent"))
613 .expect("create nested config parent");
614 fs::create_dir_all(&nested).expect("create nested dir");
615 fs::write(root.join("icp.yaml"), "").expect("write icp config");
616 fs::write(&outer_config, "[fleet]\nname = \"toko\"\n").expect("write outer config");
617 fs::write(&nested_config, "[fleet]\nname = \"toko\"\n").expect("write nested config");
618
619 let icp_root = crate::install_root::discover_canic_project_root_from(&nested)
620 .expect("discover project root")
621 .expect("project root is present");
622
623 assert_eq!(icp_root, root.canonicalize().expect("canonical root"));
624 fs::remove_dir_all(root).expect("clean temp dir");
625 }
626
627 #[test]
628 fn icp_sync_rejects_missing_fleet_configs() {
629 let root = temp_dir("canic-icp-sync-missing");
630 fs::create_dir_all(&root).expect("create root");
631
632 let err = discover_project_spec(&root, None).expect_err("missing configs should fail");
633 let message = err.to_string();
634
635 assert!(message.contains("no Canic fleet configs found under"));
636 assert!(message.contains("fleets/<fleet>/canic.toml"));
637 fs::remove_dir_all(root).expect("clean temp dir");
638 }
639}