use std::path::{Path, PathBuf};
use cargo_metadata::{MetadataCommand, TargetKind};
use serde::Serialize;
#[derive(Debug)]
pub struct CrateInfo {
pub name: String,
pub version: String,
pub manifest_path: PathBuf,
pub root: PathBuf,
pub source_files: Vec<SourceFile>,
pub entry_points: Vec<EntryPoint>,
pub dependencies: Vec<DeclaredDependency>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DependencyKind {
Normal,
Development,
Build,
}
impl DependencyKind {
fn from_cargo_kind(kind: cargo_metadata::DependencyKind) -> Self {
match kind {
cargo_metadata::DependencyKind::Development => Self::Development,
cargo_metadata::DependencyKind::Build => Self::Build,
cargo_metadata::DependencyKind::Normal | cargo_metadata::DependencyKind::Unknown => {
Self::Normal
}
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Normal => "normal",
Self::Development => "dev",
Self::Build => "build",
}
}
}
#[derive(Debug, Clone)]
pub struct DeclaredDependency {
pub name: String,
pub kind: DependencyKind,
pub code_identifier: String,
pub target: Option<String>,
pub features: Vec<String>,
pub version_req: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceKind {
Authored,
Generated,
BuildOutput,
}
impl SourceKind {
pub const fn is_locally_reportable(self) -> bool {
matches!(self, Self::Authored)
}
pub const fn label(self) -> &'static str {
match self {
Self::Authored => "authored",
Self::Generated => "generated",
Self::BuildOutput => "build_output",
}
}
}
const GENERATED_MARKERS: &[&str] = &[
"@generated",
"DO NOT EDIT",
"Code generated by",
"automatically generated",
"This file was automatically generated",
];
const HEADER_SCAN_LINES: usize = 10;
fn classify_source_kind(source: &str) -> SourceKind {
let is_generated = source
.lines()
.take(HEADER_SCAN_LINES)
.any(|line| GENERATED_MARKERS.iter().any(|marker| line.contains(marker)));
if is_generated {
SourceKind::Generated
} else {
SourceKind::Authored
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceFile {
pub path: PathBuf,
pub kind: SourceKind,
}
#[derive(Debug)]
pub struct EntryPoint {
pub kind: EntryPointKind,
pub name: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryPointKind {
Bin,
Lib,
Example,
Test,
Bench,
BuildScript,
}
impl EntryPointKind {
pub const fn label(self) -> &'static str {
match self {
Self::Bin => "bin",
Self::Lib => "lib",
Self::Example => "example",
Self::Test => "test",
Self::Bench => "bench",
Self::BuildScript => "build-script",
}
}
fn from_cargo_kind(kind: &TargetKind) -> Option<Self> {
match kind {
TargetKind::Bin => Some(Self::Bin),
TargetKind::Lib
| TargetKind::RLib
| TargetKind::DyLib
| TargetKind::CDyLib
| TargetKind::StaticLib
| TargetKind::ProcMacro => Some(Self::Lib),
TargetKind::Example => Some(Self::Example),
TargetKind::Test => Some(Self::Test),
TargetKind::Bench => Some(Self::Bench),
TargetKind::CustomBuild => Some(Self::BuildScript),
_ => None,
}
}
}
#[derive(Debug)]
pub struct Workspace {
pub root: PathBuf,
pub crates: Vec<CrateInfo>,
}
#[derive(Debug)]
pub enum IngestError {
Metadata(cargo_metadata::Error),
Io(std::io::Error),
}
impl std::fmt::Display for IngestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Metadata(err) => write!(f, "failed to read cargo metadata: {err}"),
Self::Io(err) => write!(f, "failed to walk source files: {err}"),
}
}
}
impl std::error::Error for IngestError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Metadata(err) => Some(err),
Self::Io(err) => Some(err),
}
}
}
impl From<cargo_metadata::Error> for IngestError {
fn from(err: cargo_metadata::Error) -> Self {
Self::Metadata(err)
}
}
impl From<std::io::Error> for IngestError {
fn from(err: std::io::Error) -> Self {
Self::Io(err)
}
}
pub fn load(manifest_path: Option<&Path>) -> Result<Workspace, IngestError> {
let mut cmd = MetadataCommand::new();
if let Some(path) = manifest_path {
cmd.manifest_path(path);
}
let metadata = cmd.no_deps().exec()?;
let mut package_roots: Vec<PathBuf> = Vec::new();
for package in &metadata.packages {
if let Some(root) = package.manifest_path.parent() {
package_roots.push(root.to_path_buf().into());
}
for dep in &package.dependencies {
if let Some(path) = &dep.path {
package_roots.push(path.clone().into());
}
}
}
package_roots.sort();
package_roots.dedup();
let mut crates = Vec::new();
for package in &metadata.packages {
let manifest_path: PathBuf = package.manifest_path.clone().into();
let root = manifest_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let source_files = collect_source_files(&root, &package_roots)?;
let entry_points = package
.targets
.iter()
.flat_map(|target| {
target.kind.iter().filter_map(move |kind| {
EntryPointKind::from_cargo_kind(kind).map(|kind| EntryPoint {
kind,
name: target.name.clone(),
path: target.src_path.clone().into(),
})
})
})
.collect();
let dependencies = package
.dependencies
.iter()
.map(|dep| {
let code_identifier = dep
.rename
.clone()
.unwrap_or_else(|| dep.name.replace('-', "_"));
DeclaredDependency {
name: dep.name.clone(),
kind: DependencyKind::from_cargo_kind(dep.kind),
code_identifier,
target: dep.target.as_ref().map(ToString::to_string),
features: dep.features.clone(),
version_req: dep.req.to_string(),
}
})
.collect();
crates.push(CrateInfo {
name: package.name.to_string(),
version: package.version.to_string(),
manifest_path,
root,
source_files,
entry_points,
dependencies,
});
}
crates.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Workspace {
root: metadata.workspace_root.into(),
crates,
})
}
fn collect_source_files(
root: &Path,
package_roots: &[PathBuf],
) -> std::io::Result<Vec<SourceFile>> {
let mut paths = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
let is_target_or_hidden = path.file_name().is_some_and(|name| {
name == "target" || name.to_str().is_some_and(|name| name.starts_with('.'))
});
if is_target_or_hidden || package_roots.contains(&path) {
continue;
}
stack.push(path);
} else if path.extension().is_some_and(|ext| ext == "rs") {
paths.push(path);
}
}
}
paths.sort();
let files = paths
.into_iter()
.map(|path| {
let kind = std::fs::read_to_string(&path)
.map(|source| classify_source_kind(&source))
.unwrap_or(SourceKind::Authored);
SourceFile { path, kind }
})
.collect();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TempDir;
#[test]
fn load_discovers_crate_source_files_and_entry_points() {
let dir = TempDir::new("ingest-basic");
std::fs::write(
dir.join("Cargo.toml"),
r#"
[package]
name = "fixture"
version = "0.1.0"
edition = "2021"
"#,
)
.unwrap();
std::fs::create_dir_all(dir.join("src/bin")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(dir.join("src/bin/tool.rs"), "fn main() {}\n").unwrap();
let manifest = dir.join("Cargo.toml");
let workspace = load(Some(&manifest)).unwrap();
assert_eq!(workspace.crates.len(), 1);
let krate = &workspace.crates[0];
assert_eq!(krate.name, "fixture");
let mut files: Vec<_> = krate
.source_files
.iter()
.map(|file| file.path.strip_prefix(&krate.root).unwrap().to_path_buf())
.collect();
files.sort();
assert_eq!(
files,
vec![
PathBuf::from("src/bin/tool.rs"),
PathBuf::from("src/lib.rs")
]
);
let kinds: Vec<_> = krate.entry_points.iter().map(|entry| entry.kind).collect();
assert!(kinds.contains(&EntryPointKind::Lib));
assert!(kinds.contains(&EntryPointKind::Bin));
}
#[test]
fn a_nested_path_dependency_is_not_attributed_to_the_root_crate() {
let dir = TempDir::new("ingest-nested-path-dep");
std::fs::write(
dir.join("Cargo.toml"),
r#"
[package]
name = "fixture"
version = "0.1.0"
edition = "2021"
[dependencies]
dep = { path = "vendored/dep" }
"#,
)
.unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::create_dir_all(dir.join("vendored/dep/src")).unwrap();
std::fs::write(
dir.join("vendored/dep/Cargo.toml"),
r#"
[package]
name = "dep"
version = "0.1.0"
edition = "2021"
[workspace]
"#,
)
.unwrap();
std::fs::write(dir.join("vendored/dep/src/lib.rs"), "pub fn dep() {}\n").unwrap();
let manifest = dir.join("Cargo.toml");
let workspace = load(Some(&manifest)).unwrap();
assert_eq!(workspace.crates.len(), 1);
let krate = &workspace.crates[0];
assert_eq!(krate.name, "fixture");
let files: Vec<_> = krate
.source_files
.iter()
.map(|file| file.path.strip_prefix(&krate.root).unwrap().to_path_buf())
.collect();
assert_eq!(files, vec![PathBuf::from("src/lib.rs")]);
}
#[test]
fn a_workspace_member_nested_inside_another_member_owns_its_own_files() {
let dir = TempDir::new("ingest-nested-member");
std::fs::write(
dir.join("Cargo.toml"),
r#"
[workspace]
members = ["outer", "outer/inner"]
"#,
)
.unwrap();
std::fs::create_dir_all(dir.join("outer/src")).unwrap();
std::fs::write(
dir.join("outer/Cargo.toml"),
r#"
[package]
name = "outer"
version = "0.1.0"
edition = "2021"
"#,
)
.unwrap();
std::fs::write(dir.join("outer/src/lib.rs"), "pub fn outer() {}\n").unwrap();
std::fs::create_dir_all(dir.join("outer/inner/src")).unwrap();
std::fs::write(
dir.join("outer/inner/Cargo.toml"),
r#"
[package]
name = "inner"
version = "0.1.0"
edition = "2021"
"#,
)
.unwrap();
std::fs::write(dir.join("outer/inner/src/lib.rs"), "pub fn inner() {}\n").unwrap();
let manifest = dir.join("Cargo.toml");
let workspace = load(Some(&manifest)).unwrap();
assert_eq!(workspace.crates.len(), 2);
let files_of = |name: &str| -> Vec<PathBuf> {
let krate = workspace
.crates
.iter()
.find(|krate| krate.name == name)
.unwrap();
krate
.source_files
.iter()
.map(|file| file.path.strip_prefix(&krate.root).unwrap().to_path_buf())
.collect()
};
assert_eq!(files_of("outer"), vec![PathBuf::from("src/lib.rs")]);
assert_eq!(files_of("inner"), vec![PathBuf::from("src/lib.rs")]);
}
#[test]
fn load_reports_metadata_error_for_a_missing_manifest() {
let dir = TempDir::new("ingest-missing-manifest");
let manifest = dir.join("Cargo.toml");
let err = load(Some(&manifest)).unwrap_err();
assert!(matches!(err, IngestError::Metadata(_)));
}
#[test]
fn collect_source_files_skips_the_target_directory() {
let dir = TempDir::new("ingest-skip-target");
std::fs::create_dir_all(dir.join("target/debug")).unwrap();
std::fs::write(dir.join("target/debug/generated.rs"), "// generated\n").unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
let files = collect_source_files(&dir, &[]).unwrap();
assert_eq!(
files,
vec![SourceFile {
path: dir.join("src/lib.rs"),
kind: SourceKind::Authored,
}]
);
}
#[test]
fn collect_source_files_skips_hidden_directories() {
let dir = TempDir::new("ingest-skip-hidden");
std::fs::create_dir_all(dir.join(".claude/worktrees/agent-x/src")).unwrap();
std::fs::write(
dir.join(".claude/worktrees/agent-x/src/lib.rs"),
"pub fn hidden() {}\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
let files = collect_source_files(&dir, &[]).unwrap();
assert_eq!(
files,
vec![SourceFile {
path: dir.join("src/lib.rs"),
kind: SourceKind::Authored,
}]
);
}
#[test]
fn a_header_marker_classifies_a_file_as_generated() {
let dir = TempDir::new("ingest-generated");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(
dir.join("src/schema.rs"),
"// @generated by openapi-codegen. DO NOT EDIT.\npub struct Schema;\n",
)
.unwrap();
let files = collect_source_files(&dir, &[]).unwrap();
let kind_of = |name: &str| {
files
.iter()
.find(|file| file.path.ends_with(name))
.unwrap()
.kind
};
assert_eq!(kind_of("lib.rs"), SourceKind::Authored);
assert_eq!(kind_of("schema.rs"), SourceKind::Generated);
}
#[test]
fn a_marker_outside_the_scanned_header_lines_is_not_detected() {
let dir = TempDir::new("ingest-generated-late-marker");
std::fs::create_dir_all(dir.join("src")).unwrap();
let mut source = "\n".repeat(HEADER_SCAN_LINES);
source.push_str("// @generated too late to count\n");
std::fs::write(dir.join("src/lib.rs"), source).unwrap();
let files = collect_source_files(&dir, &[]).unwrap();
assert_eq!(files[0].kind, SourceKind::Authored);
}
#[test]
fn ingest_error_source_preserves_the_underlying_error() {
let err = IngestError::Io(std::io::Error::other("boom"));
let source = std::error::Error::source(&err).expect("Io must carry a source");
assert!(source.downcast_ref::<std::io::Error>().is_some());
assert_eq!(err.to_string(), "failed to walk source files: boom");
}
}