⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/pkg/
compliance.rs

1//! License Compliance and Dependency Graph Analysis
2//!
3//! Provides license detection from text, compatibility checking between
4//! license pairs, and dependency graph operations including reverse
5//! dependency lookup, circular dependency detection, and depth calculation.
6//!
7//! NOTE: Many types in this module are forward declarations for user-space
8//! APIs. They will be exercised when user-space process execution is
9//! functional. See TODO(user-space) markers for specific activation points.
10
11// User-space API forward declarations -- see NOTE above
12
13#[cfg(feature = "alloc")]
14extern crate alloc;
15
16#[cfg(feature = "alloc")]
17use alloc::{collections::BTreeMap, string::String, vec::Vec};
18
19// ============================================================================
20// License Types and Detection
21// ============================================================================
22
23/// Known open-source and proprietary license identifiers.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum License {
26    /// MIT License
27    MIT,
28    /// Apache License 2.0
29    Apache2,
30    /// GNU General Public License v2.0
31    GPL2,
32    /// GNU General Public License v3.0
33    GPL3,
34    /// GNU Lesser General Public License v2.1
35    LGPL21,
36    /// BSD 2-Clause "Simplified" License
37    BSD2,
38    /// BSD 3-Clause "New" or "Revised" License
39    BSD3,
40    /// ISC License
41    ISC,
42    /// Mozilla Public License 2.0
43    MPL2,
44    /// Proprietary / closed-source
45    Proprietary,
46    /// License could not be determined
47    Unknown,
48}
49
50#[cfg(feature = "alloc")]
51impl License {
52    /// Return the SPDX-style identifier string.
53    pub fn as_str(self) -> &'static str {
54        match self {
55            Self::MIT => "MIT",
56            Self::Apache2 => "Apache-2.0",
57            Self::GPL2 => "GPL-2.0",
58            Self::GPL3 => "GPL-3.0",
59            Self::LGPL21 => "LGPL-2.1",
60            Self::BSD2 => "BSD-2-Clause",
61            Self::BSD3 => "BSD-3-Clause",
62            Self::ISC => "ISC",
63            Self::MPL2 => "MPL-2.0",
64            Self::Proprietary => "Proprietary",
65            Self::Unknown => "Unknown",
66        }
67    }
68
69    /// Parse a license from an SPDX-style identifier string.
70    pub fn from_spdx(s: &str) -> Self {
71        match s.trim() {
72            "MIT" => Self::MIT,
73            "Apache-2.0" | "Apache2" => Self::Apache2,
74            "GPL-2.0" | "GPL-2.0-only" | "GPL2" => Self::GPL2,
75            "GPL-3.0" | "GPL-3.0-only" | "GPL3" => Self::GPL3,
76            "LGPL-2.1" | "LGPL-2.1-only" => Self::LGPL21,
77            "BSD-2-Clause" | "BSD2" => Self::BSD2,
78            "BSD-3-Clause" | "BSD3" => Self::BSD3,
79            "ISC" => Self::ISC,
80            "MPL-2.0" | "MPL2" => Self::MPL2,
81            "Proprietary" => Self::Proprietary,
82            _ => Self::Unknown,
83        }
84    }
85
86    /// Return whether this license is copyleft (restricts derivative works).
87    pub fn is_copyleft(self) -> bool {
88        matches!(self, Self::GPL2 | Self::GPL3 | Self::LGPL21 | Self::MPL2)
89    }
90
91    /// Return whether this license is permissive.
92    pub fn is_permissive(self) -> bool {
93        matches!(
94            self,
95            Self::MIT | Self::Apache2 | Self::BSD2 | Self::BSD3 | Self::ISC
96        )
97    }
98}
99
100/// Detect a license from the full text of a LICENSE file.
101///
102/// Uses keyword matching to identify the license. Returns `License::Unknown`
103/// if no known license pattern is matched.
104#[cfg(feature = "alloc")]
105pub fn detect_license(text: &str) -> License {
106    // Normalize to lowercase for case-insensitive matching
107    let lower = text.to_lowercase();
108
109    // Check for GPL variants first (more specific before less specific)
110    if lower.contains("gnu general public license") || lower.contains("gpl") {
111        if lower.contains("version 3") || lower.contains("gpl-3") || lower.contains("gplv3") {
112            return License::GPL3;
113        }
114        if lower.contains("version 2") || lower.contains("gpl-2") || lower.contains("gplv2") {
115            return License::GPL2;
116        }
117        // Generic GPL reference without version defaults to GPL3
118        if lower.contains("general public license") {
119            return License::GPL3;
120        }
121    }
122
123    // Check for LGPL
124    if (lower.contains("lesser general public license") || lower.contains("lgpl"))
125        && (lower.contains("2.1") || lower.contains("lgpl-2.1"))
126    {
127        return License::LGPL21;
128    }
129
130    // Check for Apache
131    if (lower.contains("apache license") || lower.contains("apache-2"))
132        && (lower.contains("version 2") || lower.contains("2.0") || lower.contains("apache-2"))
133    {
134        return License::Apache2;
135    }
136
137    // Check for MIT
138    if lower.contains("mit license")
139        || lower.contains("permission is hereby granted, free of charge")
140    {
141        return License::MIT;
142    }
143
144    // Check for BSD variants
145    if lower.contains("bsd") {
146        if lower.contains("2-clause") || lower.contains("simplified") {
147            return License::BSD2;
148        }
149        if lower.contains("3-clause") || lower.contains("new") || lower.contains("revised") {
150            return License::BSD3;
151        }
152        // Generic BSD reference defaults to BSD3
153        return License::BSD3;
154    }
155
156    // Check for ISC
157    if lower.contains("isc license")
158        || lower.contains("permission to use, copy, modify, and/or distribute")
159    {
160        return License::ISC;
161    }
162
163    // Check for MPL
164    if lower.contains("mozilla public license") || lower.contains("mpl-2") {
165        return License::MPL2;
166    }
167
168    // Check for proprietary indicators
169    if lower.contains("proprietary")
170        || lower.contains("all rights reserved")
171        || lower.contains("no permission")
172    {
173        return License::Proprietary;
174    }
175
176    License::Unknown
177}
178
179// ============================================================================
180// License Compatibility
181// ============================================================================
182
183/// A conflict between two package licenses.
184#[cfg(feature = "alloc")]
185#[derive(Debug, Clone)]
186pub struct LicenseConflict {
187    /// Name of the first package.
188    pub package_a: String,
189    /// License of the first package.
190    pub license_a: License,
191    /// Name of the second package.
192    pub package_b: String,
193    /// License of the second package.
194    pub license_b: License,
195    /// Explanation of why these licenses conflict.
196    pub reason: String,
197}
198
199/// License compatibility checker.
200///
201/// Determines whether two licenses can coexist in the same dependency tree
202/// based on their distribution requirements.
203#[cfg(feature = "alloc")]
204pub struct LicenseCompatibility;
205
206#[cfg(feature = "alloc")]
207impl LicenseCompatibility {
208    /// Check if two licenses are compatible for co-distribution.
209    ///
210    /// Rules:
211    /// - Proprietary is incompatible with GPL2, GPL3, and LGPL21.
212    /// - GPL3 is incompatible with GPL2 (GPL2-only cannot upgrade to GPL3).
213    /// - Permissive licenses (MIT, BSD, ISC, Apache2) are compatible with
214    ///   everything except Proprietary.
215    /// - Unknown licenses are treated as compatible (best-effort).
216    pub fn is_compatible(a: &License, b: &License) -> bool {
217        // Unknown is treated as compatible (best-effort)
218        if *a == License::Unknown || *b == License::Unknown {
219            return true;
220        }
221
222        // Proprietary is incompatible with copyleft
223        if *a == License::Proprietary {
224            return !matches!(b, License::GPL2 | License::GPL3 | License::LGPL21);
225        }
226        if *b == License::Proprietary {
227            return !matches!(a, License::GPL2 | License::GPL3 | License::LGPL21);
228        }
229
230        // GPL3 is incompatible with GPL2-only
231        if (*a == License::GPL3 && *b == License::GPL2)
232            || (*a == License::GPL2 && *b == License::GPL3)
233        {
234            return false;
235        }
236
237        // All other combinations are compatible
238        true
239    }
240}
241
242/// Check all license pairs in a dependency list for compatibility.
243///
244/// Returns `Ok(())` if all pairs are compatible, or `Err` with a list of
245/// conflicts found.
246#[cfg(feature = "alloc")]
247pub fn check_compatibility(deps: &[(String, License)]) -> Result<(), Vec<LicenseConflict>> {
248    let mut conflicts = Vec::new();
249
250    for i in 0..deps.len() {
251        for j in (i + 1)..deps.len() {
252            let (ref name_a, ref license_a) = deps[i];
253            let (ref name_b, ref license_b) = deps[j];
254
255            if !LicenseCompatibility::is_compatible(license_a, license_b) {
256                let reason = alloc::format!(
257                    "{} ({}) is incompatible with {} ({})",
258                    license_a.as_str(),
259                    name_a,
260                    license_b.as_str(),
261                    name_b
262                );
263                conflicts.push(LicenseConflict {
264                    package_a: name_a.clone(),
265                    license_a: *license_a,
266                    package_b: name_b.clone(),
267                    license_b: *license_b,
268                    reason,
269                });
270            }
271        }
272    }
273
274    if conflicts.is_empty() {
275        Ok(())
276    } else {
277        Err(conflicts)
278    }
279}
280
281// ============================================================================
282// Dependency Graph
283// ============================================================================
284
285/// A directed dependency graph for packages.
286///
287/// Nodes are package names; edges point from a package to its dependencies.
288/// Supports reverse dependency lookup, circular dependency detection, and
289/// dependency depth calculation.
290#[cfg(feature = "alloc")]
291pub struct DependencyGraph {
292    /// Adjacency list: package -> list of its dependencies.
293    nodes: BTreeMap<String, Vec<String>>,
294}
295
296#[cfg(feature = "alloc")]
297impl DependencyGraph {
298    /// Create a new empty dependency graph.
299    pub fn new() -> Self {
300        Self {
301            nodes: BTreeMap::new(),
302        }
303    }
304
305    /// Add a package node to the graph (with no dependencies initially).
306    pub fn add_package(&mut self, name: &str) {
307        self.nodes
308            .entry(String::from(name))
309            .or_insert_with(Vec::new);
310    }
311
312    /// Add a dependency edge: `package` depends on `dependency`.
313    ///
314    /// Both nodes are created if they do not already exist.
315    pub fn add_dependency(&mut self, package: &str, dependency: &str) {
316        self.nodes
317            .entry(String::from(package))
318            .or_insert_with(Vec::new)
319            .push(String::from(dependency));
320        // Ensure the dependency node exists
321        self.nodes
322            .entry(String::from(dependency))
323            .or_insert_with(Vec::new);
324    }
325
326    /// Find all packages that depend on the given package (reverse
327    /// dependencies).
328    pub fn find_reverse_deps(&self, package: &str) -> Vec<String> {
329        let mut reverse = Vec::new();
330        for (node, deps) in &self.nodes {
331            if deps.iter().any(|d| d == package) {
332                reverse.push(node.clone());
333            }
334        }
335        reverse
336    }
337
338    /// Detect circular dependencies in the graph via DFS.
339    ///
340    /// Returns a list of cycles, where each cycle is a list of package names
341    /// forming the cycle. An empty result means no cycles exist.
342    pub fn detect_circular_deps(&self) -> Vec<Vec<String>> {
343        let mut cycles = Vec::new();
344        let mut visited: BTreeMap<String, bool> = BTreeMap::new();
345        let mut in_stack: BTreeMap<String, bool> = BTreeMap::new();
346        let mut path: Vec<String> = Vec::new();
347
348        for node in self.nodes.keys() {
349            if !visited.get(node).copied().unwrap_or(false) {
350                self.dfs_detect_cycles(node, &mut visited, &mut in_stack, &mut path, &mut cycles);
351            }
352        }
353
354        cycles
355    }
356
357    /// DFS helper for cycle detection.
358    fn dfs_detect_cycles(
359        &self,
360        node: &str,
361        visited: &mut BTreeMap<String, bool>,
362        in_stack: &mut BTreeMap<String, bool>,
363        path: &mut Vec<String>,
364        cycles: &mut Vec<Vec<String>>,
365    ) {
366        visited.insert(String::from(node), true);
367        in_stack.insert(String::from(node), true);
368        path.push(String::from(node));
369
370        if let Some(deps) = self.nodes.get(node) {
371            for dep in deps {
372                if !visited.get(dep).copied().unwrap_or(false) {
373                    self.dfs_detect_cycles(dep, visited, in_stack, path, cycles);
374                } else if in_stack.get(dep).copied().unwrap_or(false) {
375                    // Found a cycle: extract the cycle from the path
376                    let mut cycle = Vec::new();
377                    let mut found = false;
378                    for p in path.iter() {
379                        if p == dep {
380                            found = true;
381                        }
382                        if found {
383                            cycle.push(p.clone());
384                        }
385                    }
386                    cycle.push(dep.clone()); // Close the cycle
387                    cycles.push(cycle);
388                }
389            }
390        }
391
392        path.pop();
393        in_stack.insert(String::from(node), false);
394    }
395
396    /// Compute the dependency depth of a package.
397    ///
398    /// The depth is the longest path from any root (a package with no
399    /// reverse dependencies) to this package. Returns 0 if the package
400    /// is a root or is not in the graph.
401    pub fn dependency_depth(&self, package: &str) -> usize {
402        if !self.nodes.contains_key(package) {
403            return 0;
404        }
405
406        // BFS from all roots, tracking distance
407        let roots = self.find_roots();
408        if roots.is_empty() {
409            return 0;
410        }
411
412        let mut max_depth = 0;
413
414        for root in &roots {
415            let depth = self.bfs_depth(root, package);
416            if depth > max_depth {
417                max_depth = depth;
418            }
419        }
420
421        max_depth
422    }
423
424    /// Find root nodes (packages that no other package depends on).
425    pub fn find_roots(&self) -> Vec<String> {
426        let mut roots = Vec::new();
427        for node in self.nodes.keys() {
428            let has_reverse = self
429                .nodes
430                .values()
431                .any(|deps| deps.iter().any(|d| d == node));
432            if !has_reverse {
433                roots.push(node.clone());
434            }
435        }
436        roots
437    }
438
439    /// BFS to find the longest path from `start` to `target`.
440    ///
441    /// Returns 0 if `target` is not reachable from `start`.
442    fn bfs_depth(&self, start: &str, target: &str) -> usize {
443        // Use a simple recursive DFS to find the longest path
444        let mut visited = BTreeMap::new();
445        self.dfs_longest_path(start, target, &mut visited)
446    }
447
448    /// DFS helper to find the longest path length from `current` to `target`.
449    fn dfs_longest_path(
450        &self,
451        current: &str,
452        target: &str,
453        visited: &mut BTreeMap<String, bool>,
454    ) -> usize {
455        if current == target {
456            return 0;
457        }
458
459        if visited.get(current).copied().unwrap_or(false) {
460            return 0;
461        }
462
463        visited.insert(String::from(current), true);
464
465        let mut max_depth = 0;
466        let mut found = false;
467
468        if let Some(deps) = self.nodes.get(current) {
469            for dep in deps {
470                let depth = self.dfs_longest_path(dep, target, visited);
471                if dep == target || depth > 0 {
472                    found = true;
473                    let candidate = 1 + depth;
474                    if candidate > max_depth {
475                        max_depth = candidate;
476                    }
477                }
478            }
479        }
480
481        visited.insert(String::from(current), false);
482
483        if found {
484            max_depth
485        } else {
486            0
487        }
488    }
489
490    /// Return the total number of packages in the graph.
491    pub fn package_count(&self) -> usize {
492        self.nodes.len()
493    }
494
495    /// Return the total number of dependency edges.
496    pub fn edge_count(&self) -> usize {
497        self.nodes.values().map(|deps| deps.len()).sum()
498    }
499
500    /// Return the direct dependencies of a package.
501    pub fn dependencies(&self, package: &str) -> Option<&[String]> {
502        self.nodes.get(package).map(|v| v.as_slice())
503    }
504}
505
506#[cfg(feature = "alloc")]
507impl Default for DependencyGraph {
508    fn default() -> Self {
509        Self::new()
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    #[allow(unused_imports)]
516    use alloc::vec;
517
518    use super::*;
519
520    // ---- License ----
521
522    #[test]
523    fn test_license_as_str() {
524        assert_eq!(License::MIT.as_str(), "MIT");
525        assert_eq!(License::Apache2.as_str(), "Apache-2.0");
526        assert_eq!(License::GPL2.as_str(), "GPL-2.0");
527        assert_eq!(License::GPL3.as_str(), "GPL-3.0");
528        assert_eq!(License::LGPL21.as_str(), "LGPL-2.1");
529        assert_eq!(License::BSD2.as_str(), "BSD-2-Clause");
530        assert_eq!(License::BSD3.as_str(), "BSD-3-Clause");
531        assert_eq!(License::ISC.as_str(), "ISC");
532        assert_eq!(License::MPL2.as_str(), "MPL-2.0");
533        assert_eq!(License::Proprietary.as_str(), "Proprietary");
534        assert_eq!(License::Unknown.as_str(), "Unknown");
535    }
536
537    #[test]
538    fn test_license_from_spdx() {
539        assert_eq!(License::from_spdx("MIT"), License::MIT);
540        assert_eq!(License::from_spdx("Apache-2.0"), License::Apache2);
541        assert_eq!(License::from_spdx("Apache2"), License::Apache2);
542        assert_eq!(License::from_spdx("GPL-2.0"), License::GPL2);
543        assert_eq!(License::from_spdx("GPL-3.0"), License::GPL3);
544        assert_eq!(License::from_spdx("LGPL-2.1"), License::LGPL21);
545        assert_eq!(License::from_spdx("BSD-2-Clause"), License::BSD2);
546        assert_eq!(License::from_spdx("BSD-3-Clause"), License::BSD3);
547        assert_eq!(License::from_spdx("ISC"), License::ISC);
548        assert_eq!(License::from_spdx("MPL-2.0"), License::MPL2);
549        assert_eq!(License::from_spdx("Proprietary"), License::Proprietary);
550        assert_eq!(License::from_spdx("Weird"), License::Unknown);
551    }
552
553    #[test]
554    fn test_license_is_copyleft() {
555        assert!(License::GPL2.is_copyleft());
556        assert!(License::GPL3.is_copyleft());
557        assert!(License::LGPL21.is_copyleft());
558        assert!(License::MPL2.is_copyleft());
559        assert!(!License::MIT.is_copyleft());
560        assert!(!License::Apache2.is_copyleft());
561        assert!(!License::BSD3.is_copyleft());
562    }
563
564    #[test]
565    fn test_license_is_permissive() {
566        assert!(License::MIT.is_permissive());
567        assert!(License::Apache2.is_permissive());
568        assert!(License::BSD2.is_permissive());
569        assert!(License::BSD3.is_permissive());
570        assert!(License::ISC.is_permissive());
571        assert!(!License::GPL2.is_permissive());
572        assert!(!License::GPL3.is_permissive());
573        assert!(!License::Proprietary.is_permissive());
574    }
575
576    // ---- detect_license ----
577
578    #[test]
579    fn test_detect_license_mit() {
580        assert_eq!(detect_license("MIT License\nCopyright..."), License::MIT);
581        assert_eq!(
582            detect_license("Permission is hereby granted, free of charge"),
583            License::MIT
584        );
585    }
586
587    #[test]
588    fn test_detect_license_apache() {
589        assert_eq!(
590            detect_license("Apache License Version 2.0"),
591            License::Apache2
592        );
593    }
594
595    #[test]
596    fn test_detect_license_gpl3() {
597        assert_eq!(
598            detect_license("GNU General Public License version 3"),
599            License::GPL3
600        );
601    }
602
603    #[test]
604    fn test_detect_license_gpl2() {
605        assert_eq!(
606            detect_license("GNU General Public License version 2"),
607            License::GPL2
608        );
609    }
610
611    #[test]
612    fn test_detect_license_bsd2() {
613        assert_eq!(detect_license("BSD 2-Clause License"), License::BSD2);
614    }
615
616    #[test]
617    fn test_detect_license_bsd3() {
618        assert_eq!(detect_license("BSD 3-Clause License"), License::BSD3);
619    }
620
621    #[test]
622    fn test_detect_license_isc() {
623        assert_eq!(detect_license("ISC License"), License::ISC);
624    }
625
626    #[test]
627    fn test_detect_license_mpl() {
628        assert_eq!(detect_license("Mozilla Public License 2.0"), License::MPL2);
629    }
630
631    #[test]
632    fn test_detect_license_proprietary() {
633        assert_eq!(
634            detect_license("All rights reserved. No copying."),
635            License::Proprietary
636        );
637    }
638
639    #[test]
640    fn test_detect_license_unknown() {
641        assert_eq!(detect_license("Some random text"), License::Unknown);
642    }
643
644    // ---- LicenseCompatibility ----
645
646    #[test]
647    fn test_compatibility_permissive_ok() {
648        assert!(LicenseCompatibility::is_compatible(
649            &License::MIT,
650            &License::Apache2
651        ));
652        assert!(LicenseCompatibility::is_compatible(
653            &License::BSD3,
654            &License::ISC
655        ));
656    }
657
658    #[test]
659    fn test_compatibility_gpl2_gpl3_incompatible() {
660        assert!(!LicenseCompatibility::is_compatible(
661            &License::GPL2,
662            &License::GPL3
663        ));
664        assert!(!LicenseCompatibility::is_compatible(
665            &License::GPL3,
666            &License::GPL2
667        ));
668    }
669
670    #[test]
671    fn test_compatibility_proprietary_gpl_incompatible() {
672        assert!(!LicenseCompatibility::is_compatible(
673            &License::Proprietary,
674            &License::GPL3
675        ));
676        assert!(!LicenseCompatibility::is_compatible(
677            &License::GPL2,
678            &License::Proprietary
679        ));
680    }
681
682    #[test]
683    fn test_compatibility_unknown_always_compatible() {
684        assert!(LicenseCompatibility::is_compatible(
685            &License::Unknown,
686            &License::GPL3
687        ));
688        assert!(LicenseCompatibility::is_compatible(
689            &License::Proprietary,
690            &License::Unknown
691        ));
692    }
693
694    #[test]
695    fn test_compatibility_mit_gpl_ok() {
696        assert!(LicenseCompatibility::is_compatible(
697            &License::MIT,
698            &License::GPL3
699        ));
700    }
701
702    // ---- check_compatibility ----
703
704    #[test]
705    fn test_check_compatibility_ok() {
706        let deps = vec![
707            (String::from("a"), License::MIT),
708            (String::from("b"), License::Apache2),
709            (String::from("c"), License::BSD3),
710        ];
711        assert!(check_compatibility(&deps).is_ok());
712    }
713
714    #[test]
715    fn test_check_compatibility_conflict() {
716        let deps = vec![
717            (String::from("a"), License::GPL2),
718            (String::from("b"), License::GPL3),
719        ];
720        let result = check_compatibility(&deps);
721        assert!(result.is_err());
722        let conflicts = result.unwrap_err();
723        assert_eq!(conflicts.len(), 1);
724        assert_eq!(conflicts[0].package_a, "a");
725        assert_eq!(conflicts[0].package_b, "b");
726    }
727
728    // ---- DependencyGraph ----
729
730    #[test]
731    fn test_dep_graph_new() {
732        let g = DependencyGraph::new();
733        assert_eq!(g.package_count(), 0);
734        assert_eq!(g.edge_count(), 0);
735    }
736
737    #[test]
738    fn test_dep_graph_add_package() {
739        let mut g = DependencyGraph::new();
740        g.add_package("a");
741        assert_eq!(g.package_count(), 1);
742        assert_eq!(g.edge_count(), 0);
743    }
744
745    #[test]
746    fn test_dep_graph_add_dependency() {
747        let mut g = DependencyGraph::new();
748        g.add_dependency("app", "lib");
749        assert_eq!(g.package_count(), 2);
750        assert_eq!(g.edge_count(), 1);
751        let deps = g.dependencies("app").unwrap();
752        assert_eq!(deps.len(), 1);
753        assert_eq!(deps[0], "lib");
754    }
755
756    #[test]
757    fn test_dep_graph_reverse_deps() {
758        let mut g = DependencyGraph::new();
759        g.add_dependency("app", "lib");
760        g.add_dependency("tool", "lib");
761        let reverse = g.find_reverse_deps("lib");
762        assert_eq!(reverse.len(), 2);
763    }
764
765    #[test]
766    fn test_dep_graph_find_roots() {
767        let mut g = DependencyGraph::new();
768        g.add_dependency("app", "lib");
769        g.add_dependency("lib", "core");
770        let roots = g.find_roots();
771        assert_eq!(roots.len(), 1);
772        assert_eq!(roots[0], "app");
773    }
774
775    #[test]
776    fn test_dep_graph_no_cycles() {
777        let mut g = DependencyGraph::new();
778        g.add_dependency("a", "b");
779        g.add_dependency("b", "c");
780        let cycles = g.detect_circular_deps();
781        assert!(cycles.is_empty());
782    }
783
784    #[test]
785    fn test_dep_graph_with_cycle() {
786        let mut g = DependencyGraph::new();
787        g.add_dependency("a", "b");
788        g.add_dependency("b", "c");
789        g.add_dependency("c", "a");
790        let cycles = g.detect_circular_deps();
791        assert!(!cycles.is_empty());
792    }
793
794    #[test]
795    fn test_dep_graph_depth() {
796        let mut g = DependencyGraph::new();
797        g.add_dependency("app", "lib");
798        g.add_dependency("lib", "core");
799        assert_eq!(g.dependency_depth("core"), 2);
800        assert_eq!(g.dependency_depth("lib"), 1);
801        assert_eq!(g.dependency_depth("app"), 0);
802    }
803
804    #[test]
805    fn test_dep_graph_depth_not_found() {
806        let g = DependencyGraph::new();
807        assert_eq!(g.dependency_depth("nonexistent"), 0);
808    }
809
810    #[test]
811    fn test_dep_graph_dependencies_none() {
812        let g = DependencyGraph::new();
813        assert!(g.dependencies("nonexistent").is_none());
814    }
815}