Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "codeowners"
version = "0.2.15"
version = "0.2.16"
edition = "2024"

[profile.release]
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,44 @@ codeowners for-team Payroll
```

- Please update `CHANGELOG.md` and this `README.md` when making changes.

### Module layout (for library users)

- `src/runner.rs`: public façade re-exporting the API and types.
- `src/runner/api.rs`: externally available functions used by the CLI and other crates.
- `src/runner/types.rs`: `RunConfig`, `RunResult`, and runner `Error`.
- `src/ownership/`: all ownership logic (parsing, mapping, validation, generation).
- `src/ownership/codeowners_query.rs`: CODEOWNERS-only queries consumed by the façade.

Import public APIs from `codeowners::runner::*`.

### Library usage example

```rust
use codeowners::runner::{RunConfig, for_file, teams_for_files_from_codeowners};

fn main() {
let run_config = RunConfig {
project_root: std::path::PathBuf::from("."),
codeowners_file_path: std::path::PathBuf::from(".github/CODEOWNERS"),
config_path: std::path::PathBuf::from("config/code_ownership.yml"),
no_cache: true, // set false to enable on-disk caching
};

// Find owner for a single file using the optimized path (not just CODEOWNERS)
let result = for_file(&run_config, "app/models/user.rb", false);
for msg in result.info_messages { println!("{}", msg); }
for err in result.io_errors { eprintln!("io: {}", err); }
for err in result.validation_errors { eprintln!("validation: {}", err); }

// Map multiple files to teams using CODEOWNERS rules only
let files = vec![
"app/models/user.rb".to_string(),
"config/teams/payroll.yml".to_string(),
];
match teams_for_files_from_codeowners(&run_config, &files) {
Ok(map) => println!("{:?}", map),
Err(e) => eprintln!("error: {}", e),
}
}
```
8 changes: 8 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use serde::Deserialize;
use std::{fs::File, path::Path};

#[derive(Deserialize, Debug, Clone)]
pub struct Config {
Expand Down Expand Up @@ -77,6 +78,13 @@ fn default_ignore_dirs() -> Vec<String> {
]
}

impl Config {
pub fn load_from_path(path: &Path) -> std::result::Result<Self, String> {
let file = File::open(path).map_err(|e| format!("Can't open config file: {} ({})", path.to_string_lossy(), e))?;
serde_yaml::from_reader(file).map_err(|e| format!("Can't parse config file: {} ({})", path.to_string_lossy(), e))
}
}

#[cfg(test)]
mod tests {
use std::{
Expand Down
2 changes: 1 addition & 1 deletion src/crosscheck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::path::Path;
use crate::{
cache::Cache,
config::Config,
ownership::for_file_fast::find_file_owners,
ownership::file_owner_resolver::find_file_owners,
project::Project,
project_builder::ProjectBuilder,
runner::{RunConfig, RunResult, config_from_path, team_for_file_from_codeowners},
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub(crate) mod common_test;
pub mod config;
pub mod crosscheck;
pub mod ownership;
pub mod path_utils;
pub(crate) mod project;
pub mod project_builder;
pub mod project_file_builder;
Expand Down
3 changes: 2 additions & 1 deletion src/ownership.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ use std::{
use tracing::{info, instrument};

pub(crate) mod codeowners_file_parser;
pub(crate) mod codeowners_query;
mod file_generator;
mod file_owner_finder;
pub mod for_file_fast;
pub mod file_owner_resolver;
pub(crate) mod mapper;
mod validator;

Expand Down
53 changes: 53 additions & 0 deletions src/ownership/codeowners_query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};

use crate::ownership::codeowners_file_parser::Parser;
use crate::project::Team;

pub(crate) fn team_for_file_from_codeowners(
project_root: &Path,
codeowners_file_path: &Path,
team_file_globs: &[String],
file_path: &Path,
) -> Result<Option<Team>, String> {
let relative_file_path = if file_path.is_absolute() {
crate::path_utils::relative_to_buf(project_root, file_path)
} else {
PathBuf::from(file_path)
};

let parser = Parser {
codeowners_file_path: codeowners_file_path.to_path_buf(),
project_root: project_root.to_path_buf(),
team_file_globs: team_file_globs.to_vec(),
};

parser.team_from_file_path(&relative_file_path).map_err(|e| e.to_string())
}

pub(crate) fn teams_for_files_from_codeowners(
project_root: &Path,
codeowners_file_path: &Path,
team_file_globs: &[String],
file_paths: &[String],
) -> Result<HashMap<String, Option<Team>>, String> {
let relative_file_paths: Vec<PathBuf> = file_paths
.iter()
.map(Path::new)
.map(|path| {
if path.is_absolute() {
crate::path_utils::relative_to_buf(project_root, path)
} else {
path.to_path_buf()
}
})
.collect();

let parser = Parser {
codeowners_file_path: codeowners_file_path.to_path_buf(),
project_root: project_root.to_path_buf(),
team_file_globs: team_file_globs.to_vec(),
};

parser.teams_from_files_paths(&relative_file_paths).map_err(|e| e.to_string())
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path)
} else {
project_root.join(file_path)
};
let relative_file_path = absolute_file_path
.strip_prefix(project_root)
.unwrap_or(&absolute_file_path)
.to_path_buf();
let relative_file_path = crate::path_utils::relative_to_buf(project_root, &absolute_file_path);

let teams = load_teams(project_root, &config.team_file_glob)?;
let teams_by_name = build_teams_by_name_map(&teams);
Expand Down Expand Up @@ -68,7 +65,7 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path)
}

for team in &teams {
let team_rel = team.path.strip_prefix(project_root).unwrap_or(&team.path).to_path_buf();
let team_rel = crate::path_utils::relative_to_buf(project_root, &team.path);
if team_rel == relative_file_path {
sources_by_team.entry(team.name.clone()).or_default().push(Source::TeamYml);
}
Expand All @@ -77,10 +74,7 @@ pub fn find_file_owners(project_root: &Path, config: &Config, file_path: &Path)
let mut file_owners: Vec<FileOwner> = Vec::new();
for (team_name, sources) in sources_by_team.into_iter() {
if let Some(team) = teams_by_name.get(&team_name) {
let relative_team_yml_path = team
.path
.strip_prefix(project_root)
.unwrap_or(&team.path)
let relative_team_yml_path = crate::path_utils::relative_to(project_root, &team.path)
.to_string_lossy()
.to_string();
file_owners.push(FileOwner {
Expand Down Expand Up @@ -157,9 +151,7 @@ fn most_specific_directory_owner(
if let Ok(owner_str) = fs::read_to_string(&codeowner_path) {
let owner = owner_str.trim();
if let Some(team) = teams_by_name.get(owner) {
let relative_dir = current
.strip_prefix(project_root)
.unwrap_or(current.as_path())
let relative_dir = crate::path_utils::relative_to(project_root, current.as_path())
.to_string_lossy()
.to_string();
let candidate = (team.name.clone(), Source::Directory(relative_dir));
Expand Down Expand Up @@ -191,7 +183,7 @@ fn nearest_package_owner(
if !current.pop() {
break;
}
let parent_rel = current.strip_prefix(project_root).unwrap_or(current.as_path());
let parent_rel = crate::path_utils::relative_to(project_root, current.as_path());
if let Some(rel_str) = parent_rel.to_str() {
if glob_list_matches(rel_str, &config.ruby_package_paths) {
let pkg_yml = current.join("package.yml");
Expand Down
49 changes: 49 additions & 0 deletions src/path_utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use std::path::{Path, PathBuf};

/// Return `path` relative to `root` if possible; otherwise return `path` unchanged.
pub fn relative_to<'a>(root: &'a Path, path: &'a Path) -> &'a Path {
path.strip_prefix(root).unwrap_or(path)
}

/// Like `relative_to`, but returns an owned `PathBuf`.
pub fn relative_to_buf(root: &Path, path: &Path) -> PathBuf {
relative_to(root, path).to_path_buf()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn relative_to_returns_relative_when_under_root() {
let root = Path::new("/a/b");
let path = Path::new("/a/b/c/d.txt");
let rel = relative_to(root, path);
assert_eq!(rel, Path::new("c/d.txt"));
}

#[test]
fn relative_to_returns_input_when_not_under_root() {
let root = Path::new("/a/b");
let path = Path::new("/x/y/z.txt");
let rel = relative_to(root, path);
assert_eq!(rel, path);
}

#[test]
fn relative_to_handles_equal_paths() {
let root = Path::new("/a/b");
let path = Path::new("/a/b");
let rel = relative_to(root, path);
assert_eq!(rel, Path::new(""));
}

#[test]
fn relative_to_buf_matches_relative_to() {
let root = Path::new("/proj");
let path = Path::new("/proj/src/lib.rs");
let rel_ref = relative_to(root, path);
let rel_buf = relative_to_buf(root, path);
assert_eq!(rel_ref, rel_buf.as_path());
}
}
Loading