qk-graph 0.6.1

Project dependency graphs for the qk task runner
Documentation
use std::collections::BTreeSet;
use std::path::Path;

use qk_config::{Package, Workspace};
use qk_graph::{GraphReport, ProjectGraph, select_projects};
use serde_json::json;

fn example() -> Workspace {
    Workspace::load(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/basic")).unwrap()
}

fn names(values: &[&str]) -> Vec<String> {
    values.iter().map(|value| (*value).into()).collect()
}
fn set(values: &[&str]) -> BTreeSet<String> {
    names(values).into_iter().collect()
}

#[test]
fn graph_maps_package_names_to_project_names_and_exports_nx_envelope() {
    let graph = ProjectGraph::build(&example()).unwrap();
    let web = &graph.dependencies["web"];
    assert_eq!(
        web.iter()
            .map(|edge| (edge.target.as_str(), edge.kind.as_str()))
            .collect::<Vec<_>>(),
        [("codegen", "implicit"), ("core", "static")]
    );
    assert!(graph.dependencies["core"].is_empty());
    let json = serde_json::to_value(GraphReport { graph: &graph }).unwrap();
    assert_eq!(json["graph"]["nodes"]["web"]["data"]["root"], "apps/web");
    assert_eq!(json["graph"]["nodes"]["web"]["type"], "app");
    assert_eq!(json["graph"]["nodes"]["core"]["type"], "lib");
}

#[test]
fn all_manifest_dependency_kinds_contribute_deduplicated_edges() {
    let mut workspace = example();
    let core = workspace.packages.get_mut("core").unwrap();
    core.dependencies
        .insert("@example/web".into(), "workspace:*".into());
    core.dev_dependencies
        .insert("@example/web".into(), "workspace:*".into());
    core.peer_dependencies
        .insert("@example/web".into(), "*".into());
    core.optional_dependencies
        .insert("@example/web".into(), "*".into());
    core.dependencies.insert("external".into(), "1".into());
    let graph = ProjectGraph::build(&workspace).unwrap();
    assert_eq!(graph.dependencies["core"].len(), 1);
    // Cycles are legal in the project graph; reverse traversal still terminates.
    assert_eq!(
        graph.dependents_of(&set(&["core"])).unwrap(),
        set(&["core", "web", "worker"])
    );
    assert!(graph.dependents_of(&set(&["missing"])).is_err());
}

#[test]
fn every_dependency_section_works_independently() {
    for section in [
        "dependencies",
        "devDependencies",
        "peerDependencies",
        "optionalDependencies",
    ] {
        let mut workspace = example();
        let package: Package = serde_json::from_value(json!({
            "name": "@example/web", section: {"@example/core": "workspace:*"}
        }))
        .unwrap();
        workspace.packages.insert("web".into(), package);
        let graph = ProjectGraph::build(&workspace).unwrap();
        assert!(
            graph.dependencies["web"]
                .iter()
                .any(|edge| edge.target == "core"),
            "{section}"
        );
    }
}

#[test]
fn selectors_union_names_and_tags_then_apply_exclusions() {
    let workspace = example();
    let selected = select_projects(
        &workspace.projects,
        &names(&["tag:scope:*", "worker", "!core"]),
        &names(&["work*"]),
    )
    .unwrap();
    assert_eq!(selected, set(&["web"]));
    assert_eq!(
        select_projects(&workspace.projects, &names(&["!tag:scope:*"]), &[]).unwrap(),
        set(&["codegen", "worker"])
    );
    assert_eq!(
        select_projects(&workspace.projects, &names(&["absent"]), &[]).unwrap(),
        BTreeSet::new()
    );
    assert!(select_projects(&workspace.projects, &names(&["["]), &[]).is_err());
}

#[test]
fn implicit_globs_exclude_self_and_negations_remove_manifest_edges() {
    let mut workspace = example();
    workspace
        .projects
        .get_mut("web")
        .unwrap()
        .implicit_dependencies = names(&["!core", "*"]);
    let graph = ProjectGraph::build(&workspace).unwrap();
    assert_eq!(
        graph.dependencies["web"]
            .iter()
            .map(|edge| edge.target.as_str())
            .collect::<Vec<_>>(),
        ["codegen", "worker"]
    );
}

#[test]
fn graph_rejects_dangling_implicit_edges_and_duplicate_package_names() {
    let mut workspace = example();
    workspace
        .projects
        .get_mut("web")
        .unwrap()
        .implicit_dependencies = names(&["missing"]);
    assert!(
        ProjectGraph::build(&workspace)
            .unwrap_err()
            .to_string()
            .contains("unknown implicit dependency")
    );
    workspace
        .projects
        .get_mut("web")
        .unwrap()
        .implicit_dependencies
        .clear();
    workspace.packages.get_mut("web").unwrap().name = Some("@example/core".into());
    assert!(
        ProjectGraph::build(&workspace)
            .unwrap_err()
            .to_string()
            .contains("duplicate workspace package name")
    );
}

#[test]
fn output_is_independent_of_insertion_order() {
    let workspace = example();
    let first = serde_json::to_string(&ProjectGraph::build(&workspace).unwrap()).unwrap();
    let mut reordered = example();
    reordered.projects = reordered.projects.into_iter().rev().collect();
    reordered.packages = reordered.packages.into_iter().rev().collect();
    let second = serde_json::to_string(&ProjectGraph::build(&reordered).unwrap()).unwrap();
    assert_eq!(first, second);
}

#[test]
fn nodes_follow_nx_type_tag_and_edge_rules() {
    let temp = tempfile::TempDir::new().unwrap();
    let write = |path: &str, text: &str| {
        let path = temp.path().join(path);
        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
        std::fs::write(path, text).unwrap();
    };
    write(
        "nx.json",
        r#"{"workspaceLayout": {"appsDir": "apps", "libsDir": "packages"}}"#,
    );
    write(
        "package.json",
        r#"{"name": "root", "workspaces": ["apps/*", "packages/*"]}"#,
    );
    // workspaceLayout overrides the package's own projectType; keywords become tags.
    write(
        "apps/shop/package.json",
        r#"{"name": "shop", "private": true, "keywords": ["web"],
            "dependencies": {"kit": "*"},
            "nx": {"projectType": "library", "tags": ["scope:shop"], "implicitDependencies": ["kit"]}}"#,
    );
    write(
        "apps/shop/project.json",
        r#"{"tags": ["scope:shop", "extra"]}"#,
    );
    write(
        "apps/shop-e2e/package.json",
        r#"{"name": "shop-e2e", "private": true}"#,
    );
    write(
        "packages/kit/package.json",
        r#"{"name": "kit", "main": "index.js"}"#,
    );
    let workspace = Workspace::load(temp.path()).unwrap();
    assert_eq!(
        workspace.projects["shop"].tags,
        names(&["npm:private", "npm:web", "scope:shop", "extra"])
    );
    assert_eq!(workspace.projects["kit"].tags, names(&["npm:public"]));
    let graph = ProjectGraph::build(&workspace).unwrap();
    assert_eq!(graph.nodes["shop"].kind, "app");
    assert_eq!(graph.nodes["shop-e2e"].kind, "e2e");
    assert_eq!(graph.nodes["kit"].kind, "lib");
    // Nx keeps both edge kinds for one pair.
    assert_eq!(
        graph.dependencies["shop"]
            .iter()
            .map(|edge| (edge.target.as_str(), edge.kind.as_str()))
            .collect::<Vec<_>>(),
        [("kit", "implicit"), ("kit", "static")]
    );
}