Skip to main content

pitchfork_cli/
deps.rs

1use crate::Result;
2use crate::daemon::Daemon;
3use crate::daemon_id::DaemonId;
4use crate::env;
5use crate::error::{DependencyError, find_similar_daemon};
6use crate::pitchfork_toml::{PitchforkToml, PitchforkTomlDaemon};
7use crate::state_file::StateFile;
8use indexmap::IndexMap;
9use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
10use std::path::{Path, PathBuf};
11
12/// Result of dependency resolution
13#[derive(Debug)]
14pub struct DependencyOrder {
15    /// Groups of daemons that can be started in parallel.
16    /// Each level depends only on daemons in previous levels.
17    pub levels: Vec<Vec<DaemonId>>,
18}
19
20/// Resolve dependency order using Kahn's algorithm (topological sort).
21///
22/// Returns daemons grouped into levels where:
23/// - Level 0: daemons with no dependencies (or deps already satisfied)
24/// - Level 1: daemons that only depend on level 0
25/// - Level N: daemons that only depend on levels 0..(N-1)
26///
27/// Daemons within the same level can be started in parallel.
28pub fn resolve_dependencies(
29    requested: &[DaemonId],
30    all_daemons: &IndexMap<DaemonId, PitchforkTomlDaemon>,
31) -> Result<DependencyOrder> {
32    // 1. Build the full set of daemons to start (requested + transitive deps)
33    let mut to_start: HashSet<DaemonId> = HashSet::new();
34    let mut queue: VecDeque<DaemonId> = requested.iter().cloned().collect();
35
36    while let Some(id) = queue.pop_front() {
37        if to_start.contains(&id) {
38            continue;
39        }
40
41        let daemon = all_daemons.get(&id).ok_or_else(|| {
42            let suggestion = find_similar_daemon(
43                &id.qualified(),
44                all_daemons
45                    .keys()
46                    .map(|k| k.qualified())
47                    .collect::<Vec<_>>()
48                    .iter()
49                    .map(|s| s.as_str()),
50            );
51            DependencyError::DaemonNotFound {
52                name: id.qualified(),
53                suggestion,
54            }
55        })?;
56
57        to_start.insert(id.clone());
58
59        // Add dependencies to queue
60        for dep in &daemon.depends {
61            if !all_daemons.contains_key(dep) {
62                return Err(DependencyError::MissingDependency {
63                    daemon: id.qualified(),
64                    dependency: dep.qualified(),
65                }
66                .into());
67            }
68            if !to_start.contains(dep) {
69                queue.push_back(dep.clone());
70            }
71        }
72    }
73
74    // 2. Build adjacency list and in-degree map
75    let mut in_degree: HashMap<DaemonId, usize> = HashMap::new();
76    let mut dependents: HashMap<DaemonId, Vec<DaemonId>> = HashMap::new();
77
78    for id in &to_start {
79        in_degree.entry(id.clone()).or_insert(0);
80        dependents.entry(id.clone()).or_default();
81    }
82
83    for id in &to_start {
84        let daemon = all_daemons.get(id).ok_or_else(|| {
85            miette::miette!("Internal error: daemon '{}' missing from configuration", id)
86        })?;
87        for dep in &daemon.depends {
88            if to_start.contains(dep) {
89                *in_degree.get_mut(id).ok_or_else(|| {
90                    miette::miette!("Internal error: in_degree missing for daemon '{}'", id)
91                })? += 1;
92                dependents
93                    .get_mut(dep)
94                    .ok_or_else(|| {
95                        miette::miette!("Internal error: dependents missing for daemon '{}'", dep)
96                    })?
97                    .push(id.clone());
98            }
99        }
100    }
101
102    // 3. Kahn's algorithm with level tracking
103    let mut processed: HashSet<DaemonId> = HashSet::new();
104    let mut levels: Vec<Vec<DaemonId>> = Vec::new();
105    let mut current_level: Vec<DaemonId> = in_degree
106        .iter()
107        .filter(|(_, deg)| **deg == 0)
108        .map(|(id, _)| id.clone())
109        .collect();
110
111    // Sort for deterministic order
112    current_level.sort();
113
114    while !current_level.is_empty() {
115        let mut next_level = Vec::new();
116
117        for id in &current_level {
118            processed.insert(id.clone());
119
120            let deps = dependents.get(id).ok_or_else(|| {
121                miette::miette!("Internal error: dependents missing for daemon '{}'", id)
122            })?;
123            for dependent in deps {
124                let deg = in_degree.get_mut(dependent).ok_or_else(|| {
125                    miette::miette!(
126                        "Internal error: in_degree missing for daemon '{}'",
127                        dependent
128                    )
129                })?;
130                *deg -= 1;
131                if *deg == 0 {
132                    next_level.push(dependent.clone());
133                }
134            }
135        }
136
137        levels.push(current_level);
138        next_level.sort(); // Sort for deterministic order
139        current_level = next_level;
140    }
141
142    // 4. Check for cycles
143    if processed.len() != to_start.len() {
144        let mut involved: Vec<_> = to_start
145            .difference(&processed)
146            .map(|id| id.qualified())
147            .collect();
148        involved.sort(); // Deterministic output
149        return Err(DependencyError::CircularDependency { involved }.into());
150    }
151
152    Ok(DependencyOrder { levels })
153}
154
155/// Compute the order in which daemons should be stopped, respecting
156/// reverse dependency order (dependents first, then their dependencies).
157///
158/// This is a shared helper used by both the supervisor's `close()` and
159/// the IPC `stop_daemons()` batch operation.
160///
161/// The dependencies are those each daemon was started with, as recorded in
162/// the state file, so the order holds wherever `stop` runs: the config
163/// visible from the current directory need not include the daemons' own
164/// projects. Falls back to a single level containing all IDs if the state
165/// file cannot be read.
166///
167/// Reads the state file and possibly config, so an async caller runs it on a
168/// blocking worker.
169pub fn compute_reverse_stop_order(active_ids: &[DaemonId]) -> Vec<Vec<DaemonId>> {
170    if active_ids.is_empty() {
171        return Vec::new();
172    }
173    match StateFile::read(&*env::PITCHFORK_STATE_FILE) {
174        Ok(state) => reverse_stop_order(active_ids, &state.daemons),
175        Err(e) => {
176            warn!(
177                "failed to read state for dependency-ordered shutdown, stopping in arbitrary order: {e}"
178            );
179            vec![active_ids.to_vec()]
180        }
181    }
182}
183
184/// Group `active_ids` into levels to stop one after another: nothing in a
185/// level is needed by anything in a later one, directly or through daemons
186/// that are not being stopped. Each level can be stopped concurrently, and
187/// keeps the order of `active_ids`.
188///
189/// Dependencies come from the daemons' records. A daemon reached without a
190/// record, such as a disabled one that a start skipped, is looked up in the
191/// config of the project it was reached from and of every registered
192/// project, since a daemon can depend on one in another namespace. One found
193/// in neither depends on nothing. Daemons left in a dependency cycle, and what they depend on,
194/// are stopped together in the last level.
195///
196/// Looking a daemon up reads config, so an async caller runs this on a
197/// blocking worker.
198pub fn reverse_stop_order(
199    active_ids: &[DaemonId],
200    daemons: &BTreeMap<DaemonId, Daemon>,
201) -> Vec<Vec<DaemonId>> {
202    let mut configs: HashMap<PathBuf, Option<PitchforkToml>> = HashMap::new();
203    reverse_stop_order_with(active_ids, daemons, &mut |project, id| {
204        configs
205            .entry(project.to_path_buf())
206            .or_insert_with(|| PitchforkToml::all_merged_all_namespaces_from_blocking(project).ok())
207            .as_ref()
208            .and_then(|pt| pt.daemons.get(id))
209            .map(|d| d.depends.clone())
210            .unwrap_or_default()
211    })
212}
213
214/// [`reverse_stop_order`], with `unrecorded` giving the dependencies of a
215/// daemon that has no record, from the project directory it was reached from.
216fn reverse_stop_order_with(
217    active_ids: &[DaemonId],
218    daemons: &BTreeMap<DaemonId, Daemon>,
219    unrecorded: &mut dyn FnMut(&Path, &DaemonId) -> Vec<DaemonId>,
220) -> Vec<Vec<DaemonId>> {
221    let mut ids: Vec<&DaemonId> = Vec::new();
222    let mut index: HashMap<&DaemonId, usize> = HashMap::new();
223    for id in active_ids {
224        if !index.contains_key(id) {
225            index.insert(id, ids.len());
226            ids.push(id);
227        }
228    }
229
230    // Links between the daemons being stopped only: the ones each needs
231    // directly or through daemons that are not. That is as many links as the
232    // records have, rather than everything each daemon needs at any depth,
233    // and stopping by them is the same order.
234    let needs: Vec<Vec<usize>> = ids
235        .iter()
236        .map(|id| stopped_dependencies(id, daemons, &index, unrecorded))
237        .collect();
238    let mut needed_by = vec![0usize; ids.len()];
239    for deps in &needs {
240        for &dep in deps {
241            needed_by[dep] += 1;
242        }
243    }
244
245    let mut levels: Vec<Vec<DaemonId>> = Vec::new();
246    let mut placed = vec![false; ids.len()];
247    let mut level: Vec<usize> = (0..ids.len()).filter(|&i| needed_by[i] == 0).collect();
248    while !level.is_empty() {
249        let mut next = Vec::new();
250        for &i in &level {
251            placed[i] = true;
252            for &dep in &needs[i] {
253                needed_by[dep] -= 1;
254                if needed_by[dep] == 0 {
255                    next.push(dep);
256                }
257            }
258        }
259        levels.push(level.iter().map(|&i| ids[i].clone()).collect());
260        next.sort_unstable();
261        level = next;
262    }
263    let cycle: Vec<DaemonId> = (0..ids.len())
264        .filter(|&i| !placed[i])
265        .map(|i| ids[i].clone())
266        .collect();
267    if !cycle.is_empty() {
268        levels.push(cycle);
269    }
270
271    debug!("shutdown order: {levels:?}");
272    levels
273}
274
275/// The daemons being stopped (indexed by `index`) that `id` needs according
276/// to the records, directly or through daemons that are not being stopped.
277/// The search does not go past a daemon being stopped: what that one needs
278/// are its own links. A daemon without a record is resolved by `unrecorded`
279/// in the project of the recorded daemon it was reached through.
280fn stopped_dependencies(
281    id: &DaemonId,
282    daemons: &BTreeMap<DaemonId, Daemon>,
283    index: &HashMap<&DaemonId, usize>,
284    unrecorded: &mut dyn FnMut(&Path, &DaemonId) -> Vec<DaemonId>,
285) -> Vec<usize> {
286    let mut found = Vec::new();
287    let mut seen: HashSet<DaemonId> = HashSet::from([id.clone()]);
288    let mut queue: VecDeque<(DaemonId, Option<PathBuf>)> = VecDeque::from([(id.clone(), None)]);
289    while let Some((current, reached_from)) = queue.pop_front() {
290        let (depends, project) = match daemons.get(&current) {
291            Some(daemon) => (
292                daemon.depends.clone(),
293                daemon.watch_base_dir.clone().or(reached_from),
294            ),
295            None => match reached_from {
296                Some(project) => (unrecorded(&project, &current), Some(project)),
297                None => continue,
298            },
299        };
300        for dep in depends {
301            if seen.contains(&dep) {
302                continue;
303            }
304            seen.insert(dep.clone());
305            match index.get(&dep) {
306                Some(&i) => found.push(i),
307                None => queue.push_back((dep, project.clone())),
308            }
309        }
310    }
311    found
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::daemon_id::DaemonId;
318    use crate::pitchfork_toml::PitchforkTomlDaemon;
319    use indexmap::IndexMap;
320
321    // Helper to build a test daemon with only `depends` set, all other fields default/None.
322    // Keeps tests concise while satisfying all required struct fields.
323
324    fn make_daemon(depends: Vec<&str>) -> PitchforkTomlDaemon {
325        PitchforkTomlDaemon {
326            run: "echo test".into(),
327            depends: depends
328                .into_iter()
329                .map(|s| DaemonId::new("global", s))
330                .collect(),
331            ..Default::default()
332        }
333    }
334
335    fn id(name: &str) -> DaemonId {
336        DaemonId::new("global", name)
337    }
338
339    fn records(entries: &[(DaemonId, &[DaemonId])]) -> BTreeMap<DaemonId, Daemon> {
340        entries
341            .iter()
342            .map(|(id, depends)| {
343                (
344                    id.clone(),
345                    Daemon {
346                        id: id.clone(),
347                        depends: depends.to_vec(),
348                        ..Default::default()
349                    },
350                )
351            })
352            .collect()
353    }
354
355    #[test]
356    fn test_stop_order_follows_recorded_dependencies_of_any_project() {
357        let db = DaemonId::new("projb", "db");
358        let app = DaemonId::new("projb", "app");
359        let daemons = records(&[(db.clone(), &[]), (app.clone(), std::slice::from_ref(&db))]);
360        assert_eq!(
361            reverse_stop_order(&[db.clone(), app.clone()], &daemons),
362            vec![vec![app], vec![db]]
363        );
364    }
365
366    #[test]
367    fn test_stop_order_goes_through_daemons_not_being_stopped() {
368        // web needs cache only through api, which is not running.
369        let daemons = records(&[
370            (id("cache"), &[]),
371            (id("api"), &[id("cache")]),
372            (id("web"), &[id("api")]),
373        ]);
374        assert_eq!(
375            reverse_stop_order(&[id("cache"), id("web")], &daemons),
376            vec![vec![id("web")], vec![id("cache")]]
377        );
378    }
379
380    #[test]
381    fn test_stop_order_puts_unrelated_and_unrecorded_daemons_first() {
382        let daemons = records(&[(id("db"), &[]), (id("app"), &[id("db")])]);
383        assert_eq!(
384            reverse_stop_order(&[id("db"), id("adhoc"), id("app")], &daemons),
385            vec![vec![id("adhoc"), id("app")], vec![id("db")]]
386        );
387    }
388
389    #[test]
390    fn test_stop_order_goes_through_a_daemon_without_a_record_by_its_config() {
391        // api was skipped as disabled when web started, so it has no record;
392        // its project's config says it needs cache.
393        let project = PathBuf::from("/project");
394        let mut daemons = records(&[(id("cache"), &[]), (id("web"), &[id("api")])]);
395        daemons.get_mut(&id("web")).unwrap().watch_base_dir = Some(project.clone());
396        let mut looked_up = Vec::new();
397        let levels =
398            reverse_stop_order_with(&[id("cache"), id("web")], &daemons, &mut |dir, daemon| {
399                looked_up.push((dir.to_path_buf(), daemon.clone()));
400                if *daemon == id("api") {
401                    vec![id("cache")]
402                } else {
403                    vec![]
404                }
405            });
406        assert_eq!(levels, vec![vec![id("web")], vec![id("cache")]]);
407        assert_eq!(looked_up, vec![(project, id("api"))]);
408    }
409
410    #[test]
411    fn test_stop_order_stops_a_cycle_together_last() {
412        let daemons = records(&[
413            (id("a"), &[id("b")]),
414            (id("b"), &[id("a")]),
415            (id("c"), &[id("a")]),
416        ]);
417        assert_eq!(
418            reverse_stop_order(&[id("a"), id("b"), id("c")], &daemons),
419            vec![vec![id("c")], vec![id("a"), id("b")]]
420        );
421    }
422
423    /// The order as first written: every daemon's full set of transitive
424    /// dependencies, peeled one level at a time. Kept to check that the
425    /// linear version orders exactly the same.
426    fn reference_stop_order(
427        active_ids: &[DaemonId],
428        daemons: &BTreeMap<DaemonId, Daemon>,
429    ) -> Vec<Vec<DaemonId>> {
430        let all_needs = |id: &DaemonId| {
431            let mut found = HashSet::new();
432            let mut queue = VecDeque::from([id.clone()]);
433            while let Some(current) = queue.pop_front() {
434                for dep in daemons
435                    .get(&current)
436                    .map(|d| &d.depends)
437                    .into_iter()
438                    .flatten()
439                {
440                    if found.insert(dep.clone()) {
441                        queue.push_back(dep.clone());
442                    }
443                }
444            }
445            found
446        };
447        let mut remaining: Vec<DaemonId> = Vec::new();
448        for id in active_ids {
449            if !remaining.contains(id) {
450                remaining.push(id.clone());
451            }
452        }
453        let needs: HashMap<DaemonId, HashSet<DaemonId>> = remaining
454            .iter()
455            .map(|id| (id.clone(), all_needs(id)))
456            .collect();
457        let mut levels = Vec::new();
458        while !remaining.is_empty() {
459            let level: Vec<DaemonId> = remaining
460                .iter()
461                .filter(|id| {
462                    !remaining
463                        .iter()
464                        .any(|other| other != *id && needs[other].contains(*id))
465                })
466                .cloned()
467                .collect();
468            if level.is_empty() {
469                levels.push(std::mem::take(&mut remaining));
470                break;
471            }
472            remaining.retain(|id| !level.contains(id));
473            levels.push(level);
474        }
475        levels
476    }
477
478    #[test]
479    fn test_stop_order_matches_the_reference_on_random_graphs() {
480        // A small LCG keeps the graphs reproducible without a dependency.
481        let mut seed: u64 = 0x2545_f491_4f6c_dd1d;
482        let mut next = |n: u64| {
483            seed = seed
484                .wrapping_mul(6_364_136_223_846_793_005)
485                .wrapping_add(1_442_695_040_888_963_407);
486            (seed >> 33) % n
487        };
488        for _ in 0..500 {
489            let count = 1 + next(12);
490            let names: Vec<DaemonId> = (0..count).map(|i| id(&format!("d{i}"))).collect();
491            let mut deps: Vec<(DaemonId, Vec<DaemonId>)> = Vec::new();
492            for name in &names {
493                // Mostly forward links, with the odd backward one for cycles.
494                let links = (0..next(3))
495                    .map(|_| names[next(count) as usize].clone())
496                    .collect();
497                deps.push((name.clone(), links));
498            }
499            let daemons: BTreeMap<DaemonId, Daemon> = deps
500                .iter()
501                .filter(|_| next(5) != 0) // Some daemons have no record.
502                .map(|(id, depends)| {
503                    (
504                        id.clone(),
505                        Daemon {
506                            id: id.clone(),
507                            depends: depends.clone(),
508                            ..Default::default()
509                        },
510                    )
511                })
512                .collect();
513            let active: Vec<DaemonId> = names
514                .iter()
515                .filter(|_| next(3) != 0) // Some are not being stopped.
516                .cloned()
517                .collect();
518            assert_eq!(
519                reverse_stop_order(&active, &daemons),
520                reference_stop_order(&active, &daemons),
521                "active {active:?}, records {deps:?}"
522            );
523        }
524    }
525
526    #[test]
527    fn test_stop_order_of_a_long_chain() {
528        let names: Vec<DaemonId> = (0..5000).map(|i| id(&format!("d{i}"))).collect();
529        let daemons: BTreeMap<DaemonId, Daemon> = names
530            .iter()
531            .enumerate()
532            .map(|(i, name)| {
533                (
534                    name.clone(),
535                    Daemon {
536                        id: name.clone(),
537                        depends: names.get(i + 1).cloned().into_iter().collect(),
538                        ..Default::default()
539                    },
540                )
541            })
542            .collect();
543        // Every other daemon is running; the rest are links through stopped ones.
544        let active: Vec<DaemonId> = names.iter().step_by(2).cloned().collect();
545        let levels = reverse_stop_order(&active, &daemons);
546        assert_eq!(
547            levels,
548            active.iter().map(|id| vec![id.clone()]).collect::<Vec<_>>()
549        );
550    }
551
552    #[test]
553    fn test_no_dependencies() {
554        let mut daemons = IndexMap::new();
555        daemons.insert(id("api"), make_daemon(vec![]));
556
557        let result = resolve_dependencies(&[id("api")], &daemons).unwrap();
558
559        assert_eq!(result.levels.len(), 1);
560        assert_eq!(result.levels[0], vec![id("api")]);
561    }
562
563    #[test]
564    fn test_simple_dependency() {
565        let mut daemons = IndexMap::new();
566        daemons.insert(id("postgres"), make_daemon(vec![]));
567        daemons.insert(id("api"), make_daemon(vec!["postgres"]));
568
569        let result = resolve_dependencies(&[id("api")], &daemons).unwrap();
570
571        assert_eq!(result.levels.len(), 2);
572        assert_eq!(result.levels[0], vec![id("postgres")]);
573        assert_eq!(result.levels[1], vec![id("api")]);
574    }
575
576    #[test]
577    fn test_multiple_dependencies() {
578        let mut daemons = IndexMap::new();
579        daemons.insert(id("postgres"), make_daemon(vec![]));
580        daemons.insert(id("redis"), make_daemon(vec![]));
581        daemons.insert(id("api"), make_daemon(vec!["postgres", "redis"]));
582
583        let result = resolve_dependencies(&[id("api")], &daemons).unwrap();
584
585        assert_eq!(result.levels.len(), 2);
586        // postgres and redis can start in parallel
587        assert!(result.levels[0].contains(&id("postgres")));
588        assert!(result.levels[0].contains(&id("redis")));
589        assert_eq!(result.levels[1], vec![id("api")]);
590    }
591
592    #[test]
593    fn test_transitive_dependencies() {
594        let mut daemons = IndexMap::new();
595        daemons.insert(id("database"), make_daemon(vec![]));
596        daemons.insert(id("backend"), make_daemon(vec!["database"]));
597        daemons.insert(id("api"), make_daemon(vec!["backend"]));
598
599        let result = resolve_dependencies(&[id("api")], &daemons).unwrap();
600
601        assert_eq!(result.levels.len(), 3);
602        assert_eq!(result.levels[0], vec![id("database")]);
603        assert_eq!(result.levels[1], vec![id("backend")]);
604        assert_eq!(result.levels[2], vec![id("api")]);
605    }
606
607    #[test]
608    fn test_diamond_dependency() {
609        let mut daemons = IndexMap::new();
610        daemons.insert(id("db"), make_daemon(vec![]));
611        daemons.insert(id("auth"), make_daemon(vec!["db"]));
612        daemons.insert(id("data"), make_daemon(vec!["db"]));
613        daemons.insert(id("api"), make_daemon(vec!["auth", "data"]));
614
615        let result = resolve_dependencies(&[id("api")], &daemons).unwrap();
616
617        assert_eq!(result.levels.len(), 3);
618        assert_eq!(result.levels[0], vec![id("db")]);
619        // auth and data can start in parallel
620        assert!(result.levels[1].contains(&id("auth")));
621        assert!(result.levels[1].contains(&id("data")));
622        assert_eq!(result.levels[2], vec![id("api")]);
623    }
624
625    #[test]
626    fn test_circular_dependency_detected() {
627        let mut daemons = IndexMap::new();
628        daemons.insert(id("a"), make_daemon(vec!["c"]));
629        daemons.insert(id("b"), make_daemon(vec!["a"]));
630        daemons.insert(id("c"), make_daemon(vec!["b"]));
631
632        let result = resolve_dependencies(&[id("a")], &daemons);
633
634        assert!(result.is_err());
635        let err = result.unwrap_err().to_string();
636        assert!(err.contains("circular dependency"));
637    }
638
639    #[test]
640    fn test_missing_dependency_error() {
641        let mut daemons = IndexMap::new();
642        let mut daemon = make_daemon(vec![]);
643        daemon.depends = vec![DaemonId::new("global", "nonexistent")];
644        daemons.insert(id("api"), daemon);
645
646        let result = resolve_dependencies(&[id("api")], &daemons);
647
648        assert!(result.is_err());
649        let err = result.unwrap_err().to_string();
650        assert!(err.contains("nonexistent"));
651        assert!(err.contains("not defined"));
652    }
653
654    #[test]
655    fn test_missing_requested_daemon_error() {
656        let daemons = IndexMap::new();
657
658        let result = resolve_dependencies(&[id("nonexistent")], &daemons);
659
660        assert!(result.is_err());
661        let err = result.unwrap_err().to_string();
662        assert!(err.contains("nonexistent"));
663        assert!(err.contains("not found"));
664    }
665
666    #[test]
667    fn test_multiple_requested_daemons() {
668        let mut daemons = IndexMap::new();
669        daemons.insert(id("db"), make_daemon(vec![]));
670        daemons.insert(id("api"), make_daemon(vec!["db"]));
671        daemons.insert(id("worker"), make_daemon(vec!["db"]));
672
673        let result = resolve_dependencies(&[id("api"), id("worker")], &daemons).unwrap();
674
675        assert_eq!(result.levels.len(), 2);
676        assert_eq!(result.levels[0], vec![id("db")]);
677        // api and worker can start in parallel
678        assert!(result.levels[1].contains(&id("api")));
679        assert!(result.levels[1].contains(&id("worker")));
680    }
681
682    #[test]
683    fn test_start_all_with_dependencies() {
684        let mut daemons = IndexMap::new();
685        daemons.insert(id("db"), make_daemon(vec![]));
686        daemons.insert(id("cache"), make_daemon(vec![]));
687        daemons.insert(id("api"), make_daemon(vec!["db", "cache"]));
688        daemons.insert(id("worker"), make_daemon(vec!["db"]));
689
690        let all_ids: Vec<DaemonId> = daemons.keys().cloned().collect();
691        let result = resolve_dependencies(&all_ids, &daemons).unwrap();
692
693        assert_eq!(result.levels.len(), 2);
694        // db and cache have no deps
695        assert!(result.levels[0].contains(&id("db")));
696        assert!(result.levels[0].contains(&id("cache")));
697        // api and worker depend on level 0
698        assert!(result.levels[1].contains(&id("api")));
699        assert!(result.levels[1].contains(&id("worker")));
700    }
701}