Skip to main content

wowlab_tidy/infra/
workspace.rs

1//! Cargo workspace discovery: root lookup, member enumeration, and dependency roots.
2
3use std::sync::{Arc, Mutex};
4
5use wowlab_fs::{
6    file,
7    path::{Path, PathBuf},
8    working_directory,
9};
10use wowlab_types::sim::FastMap;
11
12/// Workspace member package names and the crate roots they are imported as.
13#[derive(Debug, Default)]
14pub(crate) struct WorkspaceMembers {
15    pub names: Vec<String>,
16    pub import_roots: Vec<String>,
17}
18
19/// Per-run Cargo workspace discovery cache.
20#[derive(Debug, Default)]
21pub(crate) struct WorkspaceContext {
22    external_roots: Mutex<FastMap<PathBuf, Arc<[String]>>>,
23    members: Mutex<FastMap<PathBuf, Arc<WorkspaceMembers>>>,
24}
25
26impl WorkspaceContext {
27    pub(crate) fn members(&self, start: &Path) -> Arc<WorkspaceMembers> {
28        let root = workspace_root(start).unwrap_or_default();
29        let mut cache = self
30            .members
31            .lock()
32            .expect("workspace member cache poisoned");
33
34        Arc::clone(
35            cache
36                .entry(root.clone())
37                .or_insert_with(|| Arc::new(load_members(&root))),
38        )
39    }
40
41    pub(crate) fn external_dep_roots(&self, path: &Path) -> Option<Arc<[String]>> {
42        if !path.is_absolute() {
43            return None;
44        }
45
46        let crate_dir = owning_crate_dir(path)?;
47        let mut cache = self
48            .external_roots
49            .lock()
50            .expect("dependency root cache poisoned");
51
52        Some(Arc::clone(cache.entry(crate_dir.clone()).or_insert_with(
53            || load_external_deps(&crate_dir, self).into(),
54        )))
55    }
56}
57
58impl WorkspaceMembers {
59    pub(crate) fn is_member_root(&self, root: &str) -> bool {
60        self.import_roots.iter().any(|known| known == root)
61    }
62
63    pub(crate) fn is_member_package(&self, name: &str) -> bool {
64        self.names.iter().any(|known| known == name)
65    }
66}
67
68/// Nearest ancestor of `start` whose `Cargo.toml` declares a `[workspace]` table.
69pub(crate) fn workspace_root(start: &Path) -> Option<PathBuf> {
70    let absolute = if start.is_absolute() {
71        start.to_path_buf()
72    } else {
73        working_directory::current().ok()?.join(start)
74    };
75
76    absolute
77        .ancestors()
78        .find(|dir| {
79            read_manifest(&dir.join("Cargo.toml"))
80                .is_some_and(|document| document.contains_key("workspace"))
81        })
82        .map(Path::to_path_buf)
83}
84
85/// Member `Cargo.toml` paths under `root` (gitignore-respecting), excluding `root`'s own manifest.
86pub(crate) fn member_manifests(root: &Path) -> Vec<PathBuf> {
87    let (files, _failures) =
88        wowlab_fs::walk::visible_source_files(root, &["toml"], ".tidyignore").into_parts();
89
90    files
91        .into_iter()
92        .filter(|path| {
93            path.file_name().is_some_and(|name| name == "Cargo.toml") && path.parent() != Some(root)
94        })
95        .collect()
96}
97
98fn owning_crate_dir(path: &Path) -> Option<PathBuf> {
99    path.ancestors()
100        .skip(1)
101        .find(|dir| {
102            read_manifest(&dir.join("Cargo.toml"))
103                .is_some_and(|document| document.contains_key("package"))
104        })
105        .map(Path::to_path_buf)
106}
107
108fn load_external_deps(crate_dir: &Path, workspace: &WorkspaceContext) -> Vec<String> {
109    let Some(document) = read_manifest(&crate_dir.join("Cargo.toml")) else {
110        return Vec::new();
111    };
112    let Some(dependencies) = document.get("dependencies").and_then(toml::Value::as_table) else {
113        return Vec::new();
114    };
115    let members = workspace.members(crate_dir);
116    let mut roots: Vec<String> = dependencies
117        .iter()
118        .filter(|(key, value)| {
119            let resolved = value
120                .as_table()
121                .and_then(|spec| spec.get("package"))
122                .and_then(toml::Value::as_str)
123                .unwrap_or(key);
124
125            !members.is_member_package(resolved)
126        })
127        .map(|(key, _)| key.replace('-', "_"))
128        .collect();
129
130    roots.sort();
131    roots.dedup();
132
133    roots
134}
135
136fn load_members(root: &Path) -> WorkspaceMembers {
137    let mut members = WorkspaceMembers::default();
138    let Some(document) = read_manifest(&root.join("Cargo.toml")) else {
139        return members;
140    };
141    let Some(entries) = document
142        .get("workspace")
143        .and_then(toml::Value::as_table)
144        .and_then(|workspace| workspace.get("members"))
145        .and_then(toml::Value::as_array)
146    else {
147        return members;
148    };
149
150    for dir_name in entries.iter().filter_map(toml::Value::as_str) {
151        let package = read_manifest(&root.join(dir_name).join("Cargo.toml"))
152            .and_then(|document| package_name(&document))
153            .unwrap_or_else(|| dir_name.to_owned());
154
155        push_unique(&mut members.import_roots, dir_name.replace('-', "_"));
156        push_unique(&mut members.import_roots, package.replace('-', "_"));
157        push_unique(&mut members.names, package);
158    }
159
160    members
161}
162
163fn package_name(document: &toml::Table) -> Option<String> {
164    document
165        .get("package")?
166        .as_table()?
167        .get("name")?
168        .as_str()
169        .map(String::from)
170}
171
172fn read_manifest(manifest: &Path) -> Option<toml::Table> {
173    let contents = file::read_text(manifest).ok()?;
174
175    toml::from_str(&contents).ok()
176}
177
178fn push_unique(values: &mut Vec<String>, value: String) {
179    if !values.contains(&value) {
180        values.push(value);
181    }
182}