use std::path::Path;
use std::time::{Duration, UNIX_EPOCH};
use super::MountMeta;
const MARKERS: &[(&str, &str)] = &[
("Cargo.toml", "rust"),
("package.json", "typescript"),
("go.mod", "go"),
("pyproject.toml", "python"),
("requirements.txt", "python"),
("setup.py", "python"),
("pom.xml", "java"),
("build.gradle", "java"),
("build.gradle.kts", "kotlin"),
("Gemfile", "ruby"),
("composer.json", "php"),
("mix.exs", "elixir"),
("CMakeLists.txt", "cpp"),
("Makefile", "c"),
];
const DOC_MARKERS: &[&str] = &[
"AGENTS.md",
"CLAUDE.md",
".cursorrules",
"README.md",
"GEMINI.md",
".windsurfrules",
];
const STRUCTURE_HINTS: &[(&str, &str)] = &[
("crates", "cargo-workspace"),
("packages", "monorepo"),
("apps", "monorepo"),
("libs", "monorepo"),
];
pub fn detect_meta(path: &Path) -> MountMeta {
let mut meta = MountMeta::default();
let mut found_languages: Vec<String> = Vec::new();
let mut found_markers: Vec<String> = Vec::new();
for (marker, lang) in MARKERS {
let marker_path = path.join(marker);
if marker_path.is_file() {
if !found_languages.contains(&lang.to_string()) {
found_languages.push(lang.to_string());
}
found_markers.push(marker.to_string());
extract_stack(marker, &marker_path, &mut meta.stack);
}
}
for marker in DOC_MARKERS {
let marker_path = path.join(marker);
if marker_path.is_file() {
found_markers.push(marker.to_string());
if (marker == &"AGENTS.md" || marker == &"README.md")
&& meta.summary.is_empty()
&& let Ok(content) = std::fs::read_to_string(&marker_path)
{
meta.summary = first_meaningful_line(&content);
}
}
}
for (dir, hint) in STRUCTURE_HINTS {
if path.join(dir).is_dir() && !meta.stack.contains(&hint.to_string()) {
meta.stack.push(hint.to_string());
}
}
meta.languages = found_languages;
meta.markers = found_markers;
if meta.summary.is_empty() && !meta.languages.is_empty() {
meta.summary = meta.languages.join(" + ");
}
meta
}
pub fn snapshot_markers(path: &Path) -> Vec<(std::path::PathBuf, std::time::SystemTime)> {
let all: Vec<&str> = MARKERS
.iter()
.map(|(m, _)| *m)
.chain(DOC_MARKERS.iter().copied())
.collect();
all.into_iter()
.filter_map(|m| {
let p = path.join(m);
p.metadata()
.and_then(|md| md.modified())
.ok()
.map(|t| {
let truncated = t
.duration_since(UNIX_EPOCH)
.map(|d| UNIX_EPOCH + Duration::from_secs(d.as_secs()))
.unwrap_or(t);
(p, truncated)
})
})
.collect()
}
fn extract_stack(marker: &str, path: &Path, stack: &mut Vec<String>) {
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
let push = |stack: &mut Vec<String>, s: &str| {
if s.len() >= 2 && !stack.iter().any(|e| e.eq_ignore_ascii_case(s)) {
stack.push(s.to_string());
}
};
match marker {
"Cargo.toml" => {
let mut current_section = String::new();
let dep_sections = ["dependencies", "dev-dependencies", "build-dependencies"];
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
current_section = trimmed
.trim_start_matches('[')
.trim_end_matches(']')
.to_string();
if let Some(suffix) =
crate_suffix_of_dep_section(¤t_section, &dep_sections)
{
let crate_name = suffix.split('.').next().unwrap_or(suffix);
push(stack, crate_name);
}
continue;
}
let is_bare_dep_section = dep_sections.iter().any(|ds| {
current_section == *ds || current_section == format!("workspace.{ds}")
});
if !is_bare_dep_section {
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let name = trimmed[..eq_pos].trim();
if !name.is_empty() {
push(stack, name);
}
}
}
}
"package.json" => {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
for key in &["dependencies", "devDependencies", "peerDependencies"] {
if let Some(obj) = val.get(key).and_then(|v| v.as_object()) {
for dep in obj.keys() {
push(stack, dep);
}
}
}
}
}
"go.mod" => {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("require ") || trimmed.contains(" v") {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
for part in parts {
if part.contains('/') && part.contains('.') && !part.starts_with("require")
{
if let Some(name) = part.rsplit('/').next() {
push(stack, name);
}
}
}
}
}
}
"pyproject.toml" | "requirements.txt" => {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('[') {
continue;
}
let name = trimmed
.split(['=', '<', '>', ';', '[', ' '])
.next()
.unwrap_or("")
.trim();
if !name.is_empty() {
push(stack, name);
}
}
}
_ => {}
}
stack.truncate(8);
}
fn crate_suffix_of_dep_section<'a>(section: &'a str, dep_sections: &[&str]) -> Option<&'a str> {
for ds in dep_sections {
if let Some(rest) = section.strip_prefix(&format!("{ds}.")) {
return Some(rest);
}
if let Some(rest) = section.strip_prefix(&format!("workspace.{ds}.")) {
return Some(rest);
}
}
None
}
fn first_meaningful_line(content: &str) -> String {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("```") {
continue;
}
let clean = trimmed.trim_start_matches('>').replace(['*', '`'], "");
let clean = clean.trim();
let capped = if clean.len() > 120 {
&clean[..120]
} else {
clean
};
let mut end = capped.len();
while end > 0 && !capped.is_char_boundary(end) {
end -= 1;
}
let safe = &capped[..end];
if clean.len() > 120 {
return format!("{}…", safe);
}
return safe.to_string();
}
String::new()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_detect_rust_project() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"oxios\"\n\n[dependencies]\ntokio = \"1\"\nserde = \"1\"\naxum = \"0.7\"\n",
)
.unwrap();
fs::write(dir.path().join("AGENTS.md"), "# Oxios\nAgent OS in Rust.").unwrap();
let meta = detect_meta(dir.path());
assert!(meta.languages.contains(&"rust".to_string()));
assert!(meta.markers.contains(&"Cargo.toml".to_string()));
assert!(meta.markers.contains(&"AGENTS.md".to_string()));
assert!(meta.stack.iter().any(|s| s == "tokio"));
assert!(meta.stack.iter().any(|s| s == "axum"));
assert!(!meta.summary.is_empty());
}
#[test]
fn test_extract_stack_ignores_non_dependency_sections() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
[
"[package]",
"name = \"foo\"",
"edition = \"2021\"",
"authors = [\"a\"]",
"description = \"desc\"",
"license = \"MIT\"",
"",
"[dependencies]",
"tokio = { version = \"1\", features = [\"full\"] }",
"serde = \"1.0\"",
"",
"[dev-dependencies]",
"pretty_assertions = \"1\"",
"",
"[dependencies.axum]",
"version = \"0.7\"",
"features = [\"json\"]",
]
.join("\n"),
)
.unwrap();
let meta = detect_meta(dir.path());
assert!(
meta.stack.iter().any(|s| s == "tokio"),
"tokio missing: {meta:?}"
);
assert!(
meta.stack.iter().any(|s| s == "serde"),
"serde missing: {meta:?}"
);
assert!(
meta.stack.iter().any(|s| s == "axum"),
"dotted-table crate name missing: {meta:?}"
);
assert!(
meta.stack.iter().any(|s| s == "pretty_assertions"),
"dev-dep missing: {meta:?}"
);
assert!(
!meta.stack.iter().any(|s| s == "name"),
"name leaked: {meta:?}"
);
assert!(
!meta.stack.iter().any(|s| s == "edition"),
"edition leaked: {meta:?}"
);
assert!(
!meta.stack.iter().any(|s| s == "authors"),
"authors leaked: {meta:?}"
);
assert!(
!meta.stack.iter().any(|s| s == "version"),
"version leaked: {meta:?}"
);
assert!(
!meta.stack.iter().any(|s| s == "features"),
"features leaked: {meta:?}"
);
}
#[test]
fn test_detect_node_project() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"dependencies": {"react": "^18", "next": "^14"}, "devDependencies": {"typescript": "^5"}}"#,
)
.unwrap();
let meta = detect_meta(dir.path());
assert!(meta.languages.contains(&"typescript".to_string()));
assert!(meta.stack.iter().any(|s| s == "react"));
assert!(meta.stack.iter().any(|s| s == "next"));
}
#[test]
fn test_detect_empty_dir() {
let dir = TempDir::new().unwrap();
let meta = detect_meta(dir.path());
assert!(meta.languages.is_empty());
assert!(meta.markers.is_empty());
assert!(meta.summary.is_empty());
}
#[test]
fn test_structure_hints() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("crates")).unwrap();
let meta = detect_meta(dir.path());
assert!(meta.stack.contains(&"cargo-workspace".to_string()));
}
#[test]
fn test_snapshot_markers() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
let snap = snapshot_markers(dir.path());
assert!(
snap.iter()
.any(|(p, _)| p.file_name().unwrap() == "Cargo.toml")
);
assert!(
!snap
.iter()
.any(|(p, _)| p.file_name().unwrap() == "package.json")
);
}
#[test]
fn test_first_meaningful_line() {
assert_eq!(
first_meaningful_line("# Title\n\nThis is the **summary**.\nMore."),
"This is the summary."
);
}
}