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