1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3use std::time::{Duration, Instant};
4
5use anyhow::{bail, Context, Result};
6use chrono::Utc;
7use tokio::io::AsyncReadExt;
8use tokio::process::Command;
9use tokio_util::sync::CancellationToken;
10use tokio_util::task::TaskTracker;
11use tracing::{debug, error, info, warn};
12
13use std::collections::HashMap;
14
15use crate::config::model::AddonConfig;
16use crate::config::interpolate::resolve_template;
17use crate::orchestrator::state::AddonState;
18
19pub fn toml_value_to_helm_set(value: &toml::Value) -> String {
25 match value {
26 toml::Value::String(s) => s.clone(),
27 toml::Value::Boolean(b) => b.to_string(),
28 toml::Value::Integer(i) => i.to_string(),
29 toml::Value::Float(f) => f.to_string(),
30 toml::Value::Array(arr) => {
31 let items: Vec<String> = arr.iter().map(toml_value_to_helm_set).collect();
32 format!("{{{}}}", items.join(","))
33 }
34 toml::Value::Table(_) | toml::Value::Datetime(_) => value.to_string(),
35 }
36}
37
38async fn run_helm(args: &[&str], kubeconfig: &Path, cancel: &CancellationToken) -> Result<String> {
44 let child = Command::new("helm")
45 .args(args)
46 .env("KUBECONFIG", kubeconfig)
47 .output();
48
49 let output = tokio::select! {
50 result = child => result.context("running helm")?,
51 _ = cancel.cancelled() => bail!("cancelled"),
52 };
53
54 if !output.status.success() {
55 let stderr = String::from_utf8_lossy(&output.stderr);
56 bail!(
57 "helm {} failed: {}",
58 args.first().unwrap_or(&""),
59 stderr.trim()
60 );
61 }
62
63 Ok(String::from_utf8_lossy(&output.stdout).to_string())
64}
65
66async fn run_kubectl(
68 args: &[&str],
69 kubeconfig: &Path,
70 cancel: &CancellationToken,
71) -> Result<String> {
72 let child = Command::new("kubectl")
73 .args(args)
74 .env("KUBECONFIG", kubeconfig)
75 .output();
76
77 let output = tokio::select! {
78 result = child => result.context("running kubectl")?,
79 _ = cancel.cancelled() => bail!("cancelled"),
80 };
81
82 if !output.status.success() {
83 let stderr = String::from_utf8_lossy(&output.stderr);
84 bail!(
85 "kubectl {} failed: {}",
86 args.first().unwrap_or(&""),
87 stderr.trim()
88 );
89 }
90
91 Ok(String::from_utf8_lossy(&output.stdout).to_string())
92}
93
94#[allow(clippy::too_many_arguments)]
99async fn install_helm_addon(
100 name: &str,
101 chart: &str,
102 repo: Option<&str>,
103 namespace: &str,
104 version: Option<&str>,
105 values: &BTreeMap<String, toml::Value>,
106 values_files: &[String],
107 wait: bool,
108 timeout: &str,
109 skip_crds: bool,
110 kubeconfig: &Path,
111 config_dir: &Path,
112 cancel: &CancellationToken,
113) -> Result<()> {
114 let resolved_chart = if chart.starts_with("oci://") {
116 chart.to_string()
118 } else if let Some(repo_url) = repo {
119 let repo_name = chart
122 .split('/')
123 .next()
124 .filter(|s| !s.is_empty())
125 .unwrap_or(name);
126
127 run_helm(
129 &["repo", "add", repo_name, repo_url, "--force-update"],
130 kubeconfig,
131 cancel,
132 )
133 .await
134 .with_context(|| format!("adding helm repo for addon '{}'", name))?;
135
136 run_helm(&["repo", "update", repo_name], kubeconfig, cancel)
137 .await
138 .with_context(|| format!("updating helm repo for addon '{}'", name))?;
139
140 chart.to_string()
141 } else {
142 let chart_path = if Path::new(chart).is_absolute() {
144 std::path::PathBuf::from(chart)
145 } else {
146 config_dir.join(chart)
147 };
148 if !chart_path.exists() {
149 bail!(
150 "local helm chart path '{}' does not exist (resolved from '{}')",
151 chart_path.display(),
152 chart
153 );
154 }
155 chart_path.to_string_lossy().to_string()
156 };
157
158 let mut args: Vec<String> = vec![
160 "upgrade".to_string(),
161 "--install".to_string(),
162 name.to_string(),
163 resolved_chart,
164 "--namespace".to_string(),
165 namespace.to_string(),
166 "--create-namespace".to_string(),
167 ];
168
169 if skip_crds {
170 args.push("--skip-crds".to_string());
171 }
172
173 if wait {
174 args.push("--wait".to_string());
175 args.push("--timeout".to_string());
176 args.push(timeout.to_string());
177 }
178
179 if let Some(v) = version {
180 args.push("--version".to_string());
181 args.push(v.to_string());
182 }
183
184 for vf in values_files {
186 let vf_path = if Path::new(vf).is_absolute() {
187 std::path::PathBuf::from(vf)
188 } else {
189 config_dir.join(vf)
190 };
191 args.push("-f".to_string());
192 args.push(vf_path.to_string_lossy().to_string());
193 }
194
195 for (k, v) in values {
197 args.push("--set".to_string());
198 args.push(format!("{}={}", k, toml_value_to_helm_set(v)));
199 }
200
201 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
202 run_helm(&arg_refs, kubeconfig, cancel)
203 .await
204 .with_context(|| format!("installing helm addon '{}'", name))?;
205
206 debug!(addon = %name, chart = %chart, "helm addon installed");
207 Ok(())
208}
209
210fn is_crd_not_ready(err: &anyhow::Error) -> bool {
213 let msg = err.to_string();
214 msg.contains("resource mapping not found") || msg.contains("no matches for kind")
215}
216
217const CRD_RETRY_TIMEOUT: Duration = Duration::from_secs(300);
219
220fn resolve_manifest_templates(
223 manifest_path: &Path,
224 template_vars: &HashMap<String, String>,
225 addon_name: &str,
226) -> Result<Option<std::path::PathBuf>> {
227 let content = std::fs::read_to_string(manifest_path)
228 .with_context(|| format!("reading manifest '{}'", manifest_path.display()))?;
229
230 if !content.contains("{{") {
231 return Ok(None);
232 }
233
234 let field_ctx = format!("cluster.addons.{addon_name}.path");
235 let resolved = resolve_template(&content, template_vars, &field_ctx).map_err(|errs| {
236 let msgs: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
237 anyhow::anyhow!("{}", msgs.join("; "))
238 })?;
239
240 let tmp_path = std::env::temp_dir().join(format!("devrig-addon-{addon_name}.yaml"));
241 std::fs::write(&tmp_path, resolved.as_bytes())
242 .with_context(|| format!("writing resolved manifest to '{}'", tmp_path.display()))?;
243 Ok(Some(tmp_path))
244}
245
246async fn install_manifest_addon(
247 name: &str,
248 path: &str,
249 namespace: Option<&str>,
250 template_vars: &HashMap<String, String>,
251 kubeconfig: &Path,
252 config_dir: &Path,
253 cancel: &CancellationToken,
254) -> Result<()> {
255 let manifest_path = if Path::new(path).is_absolute() {
256 std::path::PathBuf::from(path)
257 } else {
258 config_dir.join(path)
259 };
260
261 let resolved = resolve_manifest_templates(&manifest_path, template_vars, name)?;
263 let apply_path = resolved
264 .as_ref()
265 .map(|p| p.to_string_lossy().to_string())
266 .unwrap_or_else(|| manifest_path.to_string_lossy().to_string());
267
268 let mut args = vec!["apply", "-f", &apply_path];
269 let ns_str;
270 if let Some(ns) = namespace {
271 ns_str = ns.to_string();
272 args.push("--namespace");
273 args.push(&ns_str);
274 }
275
276 let deadline = Instant::now() + CRD_RETRY_TIMEOUT;
277 let mut backoff = Duration::from_secs(2);
278
279 loop {
280 match run_kubectl(&args, kubeconfig, cancel).await {
281 Ok(_) => {
282 debug!(addon = %name, path = %path, "manifest addon installed");
283 return Ok(());
284 }
285 Err(e) if is_crd_not_ready(&e) && Instant::now() < deadline => {
286 info!(
287 addon = %name,
288 "CRDs not yet available, retrying in {:?}",
289 backoff
290 );
291 tokio::select! {
292 _ = tokio::time::sleep(backoff) => {}
293 _ = cancel.cancelled() => bail!("cancelled"),
294 }
295 backoff = (backoff * 2).min(Duration::from_secs(30));
296 }
297 Err(e) => {
298 return Err(e)
299 .with_context(|| format!("applying manifest addon '{}'", name));
300 }
301 }
302 }
303}
304
305async fn install_kustomize_addon(
306 name: &str,
307 path: &str,
308 namespace: Option<&str>,
309 _template_vars: &HashMap<String, String>,
310 kubeconfig: &Path,
311 config_dir: &Path,
312 cancel: &CancellationToken,
313) -> Result<()> {
314 let kustomize_path = if Path::new(path).is_absolute() {
315 std::path::PathBuf::from(path)
316 } else {
317 config_dir.join(path)
318 };
319 let kustomize_str = kustomize_path.to_string_lossy().to_string();
320
321 let mut args = vec!["apply", "-k", &kustomize_str];
322 let ns_str;
323 if let Some(ns) = namespace {
324 ns_str = ns.to_string();
325 args.push("--namespace");
326 args.push(&ns_str);
327 }
328
329 let deadline = Instant::now() + CRD_RETRY_TIMEOUT;
330 let mut backoff = Duration::from_secs(2);
331
332 loop {
333 match run_kubectl(&args, kubeconfig, cancel).await {
334 Ok(_) => {
335 debug!(addon = %name, path = %path, "kustomize addon installed");
336 return Ok(());
337 }
338 Err(e) if is_crd_not_ready(&e) && Instant::now() < deadline => {
339 info!(
340 addon = %name,
341 "CRDs not yet available, retrying in {:?}",
342 backoff
343 );
344 tokio::select! {
345 _ = tokio::time::sleep(backoff) => {}
346 _ = cancel.cancelled() => bail!("cancelled"),
347 }
348 backoff = (backoff * 2).min(Duration::from_secs(30));
349 }
350 Err(e) => {
351 return Err(e)
352 .with_context(|| format!("applying kustomize addon '{}'", name));
353 }
354 }
355 }
356}
357
358pub fn topo_sort_addons(addons: &BTreeMap<String, AddonConfig>) -> Result<Vec<String>> {
367 let mut in_degree: BTreeMap<&str, usize> = BTreeMap::new();
369 let mut dependents: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
370
371 for name in addons.keys() {
372 in_degree.entry(name.as_str()).or_insert(0);
373 }
374
375 for (name, addon) in addons {
376 for dep in addon.depends_on() {
377 if addons.contains_key(dep.as_str()) {
379 dependents
380 .entry(dep.as_str())
381 .or_default()
382 .insert(name.as_str());
383 *in_degree.entry(name.as_str()).or_insert(0) += 1;
384 }
385 }
386 }
387
388 let mut ready: BTreeSet<&str> = BTreeSet::new();
390 for (&name, °) in &in_degree {
391 if deg == 0 {
392 ready.insert(name);
393 }
394 }
395
396 let mut sorted: Vec<String> = Vec::with_capacity(addons.len());
397
398 while let Some(&name) = ready.iter().next() {
399 ready.remove(name);
400 sorted.push(name.to_string());
401
402 if let Some(deps) = dependents.get(name) {
403 for &dependent in deps {
404 let deg = in_degree.get_mut(dependent).unwrap();
405 *deg -= 1;
406 if *deg == 0 {
407 ready.insert(dependent);
408 }
409 }
410 }
411 }
412
413 if sorted.len() != addons.len() {
414 let in_cycle: Vec<String> = in_degree
416 .iter()
417 .filter(|(_, °)| deg > 0)
418 .map(|(&name, _)| name.to_string())
419 .collect();
420 bail!(
421 "addon dependency cycle detected involving: {}",
422 in_cycle.join(", ")
423 );
424 }
425
426 Ok(sorted)
427}
428
429fn resolve_values_templates(
435 values: &BTreeMap<String, toml::Value>,
436 template_vars: &HashMap<String, String>,
437 addon_name: &str,
438) -> Result<BTreeMap<String, toml::Value>> {
439 let mut resolved = BTreeMap::new();
440 for (key, value) in values {
441 let resolved_val = match value {
442 toml::Value::String(s) => {
443 let field_ctx = format!("cluster.addons.{addon_name}.values.{key}");
444 match resolve_template(s, template_vars, &field_ctx) {
445 Ok(r) => toml::Value::String(r),
446 Err(errs) => {
447 let msgs: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
448 bail!("{}", msgs.join("; "));
449 }
450 }
451 }
452 other => other.clone(),
453 };
454 resolved.insert(key.clone(), resolved_val);
455 }
456 Ok(resolved)
457}
458
459pub async fn install_addons(
462 addons: &BTreeMap<String, AddonConfig>,
463 template_vars: &HashMap<String, String>,
464 kubeconfig: &Path,
465 config_dir: &Path,
466 cancel: &CancellationToken,
467) -> Result<BTreeMap<String, AddonState>> {
468 let mut states = BTreeMap::new();
469 let install_order = topo_sort_addons(addons)?;
470
471 for name in &install_order {
472 let addon = &addons[name];
473 debug!(addon = %name, type_ = %addon.addon_type(), "installing addon");
474
475 match addon {
476 AddonConfig::Helm {
477 chart,
478 repo,
479 namespace,
480 version,
481 values,
482 values_files,
483 wait,
484 timeout,
485 skip_crds,
486 ..
487 } => {
488 let resolved_values =
489 resolve_values_templates(values, template_vars, name)?;
490 install_helm_addon(
491 name,
492 chart,
493 repo.as_deref(),
494 namespace,
495 version.as_deref(),
496 &resolved_values,
497 values_files,
498 *wait,
499 timeout,
500 *skip_crds,
501 kubeconfig,
502 config_dir,
503 cancel,
504 )
505 .await?;
506 states.insert(
507 name.clone(),
508 AddonState {
509 addon_type: "helm".to_string(),
510 namespace: namespace.clone(),
511 installed_at: Utc::now(),
512 },
513 );
514 }
515 AddonConfig::Manifest {
516 path, namespace, ..
517 } => {
518 install_manifest_addon(
519 name,
520 path,
521 namespace.as_deref(),
522 template_vars,
523 kubeconfig,
524 config_dir,
525 cancel,
526 )
527 .await?;
528 states.insert(
529 name.clone(),
530 AddonState {
531 addon_type: "manifest".to_string(),
532 namespace: namespace.as_deref().unwrap_or("default").to_string(),
533 installed_at: Utc::now(),
534 },
535 );
536 }
537 AddonConfig::Kustomize {
538 path, namespace, ..
539 } => {
540 install_kustomize_addon(
541 name,
542 path,
543 namespace.as_deref(),
544 template_vars,
545 kubeconfig,
546 config_dir,
547 cancel,
548 )
549 .await?;
550 states.insert(
551 name.clone(),
552 AddonState {
553 addon_type: "kustomize".to_string(),
554 namespace: namespace.as_deref().unwrap_or("default").to_string(),
555 installed_at: Utc::now(),
556 },
557 );
558 }
559 }
560 }
561
562 Ok(states)
563}
564
565pub async fn uninstall_addons(
567 addons: &BTreeMap<String, AddonConfig>,
568 kubeconfig: &Path,
569 config_dir: &Path,
570 cancel: &CancellationToken,
571) {
572 let uninstall_order: Vec<String> = match topo_sort_addons(addons) {
574 Ok(order) => order.into_iter().rev().collect(),
575 Err(_) => addons.keys().rev().cloned().collect(), };
577
578 for name in &uninstall_order {
579 let addon = &addons[name];
580 debug!(addon = %name, "uninstalling addon");
581 let result = match addon {
582 AddonConfig::Helm { namespace, .. } => {
583 run_helm(
584 &["uninstall", name, "--namespace", namespace],
585 kubeconfig,
586 cancel,
587 )
588 .await
589 }
590 AddonConfig::Manifest {
591 path, namespace, ..
592 } => {
593 let manifest_path = if Path::new(path.as_str()).is_absolute() {
594 std::path::PathBuf::from(path)
595 } else {
596 config_dir.join(path)
597 };
598 let manifest_str = manifest_path.to_string_lossy().to_string();
599 let mut args = vec!["delete", "-f", &manifest_str, "--ignore-not-found"];
600 let ns_str;
601 if let Some(ns) = namespace.as_deref() {
602 ns_str = ns.to_string();
603 args.push("--namespace");
604 args.push(&ns_str);
605 }
606 run_kubectl(&args, kubeconfig, cancel).await
607 }
608 AddonConfig::Kustomize {
609 path, namespace, ..
610 } => {
611 let kustomize_path = if Path::new(path.as_str()).is_absolute() {
612 std::path::PathBuf::from(path)
613 } else {
614 config_dir.join(path)
615 };
616 let kustomize_str = kustomize_path.to_string_lossy().to_string();
617 let mut args = vec!["delete", "-k", &kustomize_str, "--ignore-not-found"];
618 let ns_str;
619 if let Some(ns) = namespace.as_deref() {
620 ns_str = ns.to_string();
621 args.push("--namespace");
622 args.push(&ns_str);
623 }
624 run_kubectl(&args, kubeconfig, cancel).await
625 }
626 };
627
628 if let Err(e) = result {
629 warn!(addon = %name, error = %e, "failed to uninstall addon");
630 }
631 }
632}
633
634pub struct PortForwardManager {
640 tracker: TaskTracker,
641 cancel: CancellationToken,
642}
643
644impl Default for PortForwardManager {
645 fn default() -> Self {
646 Self::new()
647 }
648}
649
650impl PortForwardManager {
651 pub fn new() -> Self {
653 Self {
654 tracker: TaskTracker::new(),
655 cancel: CancellationToken::new(),
656 }
657 }
658
659 pub fn start_port_forwards(&self, addons: &BTreeMap<String, AddonConfig>, kubeconfig: &Path) {
661 for (name, addon) in addons {
662 let namespace = addon.namespace().unwrap_or("default").to_string();
663
664 for (port_str, target) in addon.port_forward() {
665 let local_port = match port_str.parse::<u16>() {
666 Ok(p) => p,
667 Err(_) => {
668 warn!(addon = %name, port = %port_str, "invalid port-forward port, skipping");
669 continue;
670 }
671 };
672
673 let (resource, remote_port) = match target.rsplit_once(':') {
675 Some((r, p)) => (r.to_string(), p.to_string()),
676 None => {
677 warn!(addon = %name, target = %target, "invalid port-forward target, expected resource:port");
678 continue;
679 }
680 };
681
682 let cancel = self.cancel.clone();
683 let kubeconfig = kubeconfig.to_path_buf();
684 let addon_name = name.clone();
685 let ns = namespace.clone();
686
687 self.tracker.spawn(async move {
688 let mut backoff = Duration::from_secs(1);
689 let max_backoff = Duration::from_secs(30);
690
691 loop {
692 debug!(
693 addon = %addon_name,
694 local_port = local_port,
695 target = format!("{}:{}", resource, remote_port),
696 "starting port-forward"
697 );
698
699 let mut child = match Command::new("kubectl")
700 .args([
701 "port-forward",
702 "--namespace",
703 &ns,
704 "--address",
705 "127.0.0.1",
706 &resource,
707 &format!("{}:{}", local_port, remote_port),
708 ])
709 .env("KUBECONFIG", &kubeconfig)
710 .stdout(std::process::Stdio::null())
711 .stderr(std::process::Stdio::piped())
712 .kill_on_drop(true)
713 .spawn()
714 {
715 Ok(child) => child,
716 Err(e) => {
717 error!(addon = %addon_name, error = %e, "failed to spawn port-forward");
718 break;
719 }
720 };
721
722 let stderr_handle = child.stderr.take();
723 let started = Instant::now();
724
725 tokio::select! {
726 status = child.wait() => {
727 let reason = if let Some(mut stderr) = stderr_handle {
729 let mut buf = String::new();
730 let _ = stderr.read_to_string(&mut buf).await;
731 if !buf.is_empty() {
732 debug!(
733 addon = %addon_name,
734 stderr = %buf.trim(),
735 "kubectl port-forward stderr"
736 );
737 }
738 buf.lines()
740 .rev()
741 .find(|l| l.starts_with("error:"))
742 .map(|l| l.trim_start_matches("error:").trim().to_string())
743 } else {
744 None
745 };
746
747 match status {
748 Ok(s) => {
749 warn!(
750 addon = %addon_name,
751 local_port = local_port,
752 exit = %s,
753 reason = reason.as_deref().unwrap_or("unknown"),
754 "port-forward exited, reconnecting in {:?}",
755 backoff
756 );
757 }
758 Err(e) => {
759 warn!(
760 addon = %addon_name,
761 error = %e,
762 reason = reason.as_deref().unwrap_or("unknown"),
763 "port-forward error, reconnecting in {:?}",
764 backoff
765 );
766 }
767 }
768
769 tokio::time::sleep(backoff).await;
770
771 if started.elapsed() > Duration::from_secs(60) {
773 backoff = Duration::from_secs(1);
774 } else {
775 backoff = (backoff * 2).min(max_backoff);
776 }
777 }
778 _ = cancel.cancelled() => {
779 let _ = child.kill().await;
780 debug!(addon = %addon_name, local_port = local_port, "port-forward stopped");
781 break;
782 }
783 }
784 }
785 });
786 }
787 }
788 }
789
790 pub async fn stop(&self) {
792 self.cancel.cancel();
793 self.tracker.close();
794 match tokio::time::timeout(Duration::from_secs(5), self.tracker.wait()).await {
795 Ok(()) => debug!("all port-forwards stopped"),
796 Err(_) => warn!("port-forward shutdown timed out"),
797 }
798 }
799}
800
801#[cfg(test)]
806mod tests {
807 use super::*;
808
809 #[test]
810 fn toml_value_string() {
811 let val = toml::Value::String("hello".to_string());
812 assert_eq!(toml_value_to_helm_set(&val), "hello");
813 }
814
815 #[test]
816 fn toml_value_bool_true() {
817 let val = toml::Value::Boolean(true);
818 assert_eq!(toml_value_to_helm_set(&val), "true");
819 }
820
821 #[test]
822 fn toml_value_bool_false() {
823 let val = toml::Value::Boolean(false);
824 assert_eq!(toml_value_to_helm_set(&val), "false");
825 }
826
827 #[test]
828 fn toml_value_integer() {
829 let val = toml::Value::Integer(42);
830 assert_eq!(toml_value_to_helm_set(&val), "42");
831 }
832
833 #[test]
834 fn toml_value_float() {
835 let val = toml::Value::Float(3.14);
836 assert_eq!(toml_value_to_helm_set(&val), "3.14");
837 }
838
839 #[test]
840 fn toml_value_array() {
841 let val = toml::Value::Array(vec![
842 toml::Value::String("a".to_string()),
843 toml::Value::String("b".to_string()),
844 toml::Value::String("c".to_string()),
845 ]);
846 assert_eq!(toml_value_to_helm_set(&val), "{a,b,c}");
847 }
848
849 fn manifest_addon(deps: Vec<&str>) -> AddonConfig {
851 AddonConfig::Manifest {
852 path: "./test.yaml".to_string(),
853 namespace: None,
854 port_forward: BTreeMap::new(),
855 depends_on: deps.into_iter().map(String::from).collect(),
856 }
857 }
858
859 #[test]
860 fn topo_sort_no_deps_is_alphabetical() {
861 let mut addons = BTreeMap::new();
862 addons.insert("charlie".to_string(), manifest_addon(vec![]));
863 addons.insert("alpha".to_string(), manifest_addon(vec![]));
864 addons.insert("bravo".to_string(), manifest_addon(vec![]));
865
866 let order = topo_sort_addons(&addons).unwrap();
867 assert_eq!(order, vec!["alpha", "bravo", "charlie"]);
868 }
869
870 #[test]
871 fn topo_sort_respects_depends_on() {
872 let mut addons = BTreeMap::new();
873 addons.insert("app".to_string(), manifest_addon(vec!["cert-manager"]));
874 addons.insert("cert-manager".to_string(), manifest_addon(vec![]));
875
876 let order = topo_sort_addons(&addons).unwrap();
877 assert_eq!(order, vec!["cert-manager", "app"]);
878 }
879
880 #[test]
881 fn topo_sort_diamond_deps() {
882 let mut addons = BTreeMap::new();
883 addons.insert("d".to_string(), manifest_addon(vec!["b", "c"]));
884 addons.insert("b".to_string(), manifest_addon(vec!["a"]));
885 addons.insert("c".to_string(), manifest_addon(vec!["a"]));
886 addons.insert("a".to_string(), manifest_addon(vec![]));
887
888 let order = topo_sort_addons(&addons).unwrap();
889 let pos = |n: &str| order.iter().position(|x| x == n).unwrap();
891 assert!(pos("a") < pos("b"));
892 assert!(pos("a") < pos("c"));
893 assert!(pos("b") < pos("d"));
894 assert!(pos("c") < pos("d"));
895 }
896
897 #[test]
898 fn topo_sort_detects_cycle() {
899 let mut addons = BTreeMap::new();
900 addons.insert("a".to_string(), manifest_addon(vec!["b"]));
901 addons.insert("b".to_string(), manifest_addon(vec!["a"]));
902
903 let result = topo_sort_addons(&addons);
904 assert!(result.is_err());
905 assert!(result.unwrap_err().to_string().contains("cycle"));
906 }
907
908 #[test]
909 fn topo_sort_ignores_external_deps() {
910 let mut addons = BTreeMap::new();
912 addons.insert("app".to_string(), manifest_addon(vec!["external-thing"]));
913
914 let order = topo_sort_addons(&addons).unwrap();
915 assert_eq!(order, vec!["app"]);
916 }
917}