use crate::config::{HostScope, NetworkScope, UserScope};
use serde::Deserialize;
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct ResolvedProject {
pub root: std::path::PathBuf,
pub id: String,
pub name: String,
pub description: Option<String>,
pub tags: Vec<String>,
pub enable_bundles: Vec<String>,
pub unknown_fields: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ProjectFile {
#[serde(default)]
id: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
enable_bundles: Vec<String>,
#[serde(flatten)]
extra: BTreeMap<String, serde_yaml::Value>,
}
#[derive(Debug, Clone)]
pub struct Env {
pub hostname: String,
pub user: String,
pub cwd: String,
pub gateway_mac: Option<String>,
pub home: Option<std::path::PathBuf>,
}
impl Env {
#[must_use]
pub fn empty() -> Self {
Self {
hostname: String::new(),
user: String::new(),
cwd: String::new(),
gateway_mac: None,
home: None,
}
}
#[must_use]
pub fn detect() -> Self {
let hostname = detect_hostname().unwrap_or_else(|| {
tracing::warn!("hostname detection failed; host-scope matching disabled");
String::new()
});
let user = std::env::var("USER").unwrap_or_else(|_| {
tracing::warn!("$USER unset; user-scope matching disabled");
String::new()
});
let cwd = std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_else(|| {
tracing::warn!("current_dir() unavailable; project-scope matching disabled");
String::new()
});
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
Self {
hostname: hostname.to_ascii_lowercase(),
user,
cwd,
gateway_mac: super::network::detect_gateway_mac(),
home,
}
}
}
fn detect_hostname() -> Option<String> {
super::capture_stdout("hostname detection", "hostname", &[]).map(|s| s.trim().to_string())
}
#[must_use]
pub fn matches_network(s: &NetworkScope, env: &Env) -> bool {
let Some(want) = s.r#match.gateway_mac.as_deref() else {
return false;
};
env.gateway_mac
.as_deref()
.is_some_and(|got| got.eq_ignore_ascii_case(want))
}
#[must_use]
pub fn matches_host(s: &HostScope, env: &Env) -> bool {
s.r#match
.hostname
.as_deref()
.is_some_and(|h| h.eq_ignore_ascii_case(&env.hostname))
}
#[must_use]
pub fn matches_user(s: &UserScope, env: &Env) -> bool {
s.r#match.user.as_deref().is_some_and(|u| u == env.user)
}
#[must_use]
pub fn discover_project(env: &Env) -> Option<ResolvedProject> {
let mut cur = std::path::PathBuf::from(&env.cwd);
loop {
let marker_path = cur.join(".llmenv.yaml");
if marker_path.exists() {
let pf = read_project_file(&marker_path);
let basename = cur
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("llmenv")
.to_string();
let id = pf.id.unwrap_or_else(|| basename.clone());
let name = pf.name.unwrap_or_else(|| basename.clone());
let unknown_fields: Vec<String> = pf
.extra
.keys()
.filter(|k| {
!matches!(
k.as_str(),
"id" | "name" | "description" | "tags" | "enable_bundles"
)
})
.cloned()
.collect();
return Some(ResolvedProject {
root: cur,
id,
name,
description: pf.description,
tags: pf.tags,
enable_bundles: pf.enable_bundles,
unknown_fields,
});
}
match &env.home {
Some(h) if cur == *h => break,
None => break,
_ => {}
}
if !cur.pop() {
break;
}
}
None
}
const MAX_DESCRIPTION_BYTES: usize = 1024;
fn read_project_file(path: &std::path::Path) -> ProjectFile {
let Ok(body) = std::fs::read_to_string(path) else {
return ProjectFile::default();
};
if body.trim().is_empty() {
return ProjectFile::default();
}
match serde_yaml::from_str::<ProjectFile>(&body) {
Ok(mut pf) => {
if let Some(desc) = pf.description.as_mut()
&& desc.len() > MAX_DESCRIPTION_BYTES
{
tracing::warn!(
"project marker file {} has description >{} bytes; truncating",
path.display(),
MAX_DESCRIPTION_BYTES
);
let mut cut = MAX_DESCRIPTION_BYTES;
while cut > 0 && !desc.is_char_boundary(cut) {
cut -= 1;
}
desc.truncate(cut);
}
pf
}
Err(e) => {
tracing::warn!(
"project marker file {} is not valid YAML: {e}; using defaults",
path.display()
);
ProjectFile::default()
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::{Env, discover_project};
use proptest::prelude::*;
use std::path::Path;
fn write_project_file(temp_dir: &Path, body: &str) {
let path = temp_dir.join(".llmenv.yaml");
std::fs::write(&path, body).expect("write .llmenv.yaml");
}
fn env_in(cwd: &Path, home: &Path) -> Env {
Env {
hostname: String::new(),
user: String::new(),
cwd: cwd.to_string_lossy().to_string(),
gateway_mac: None,
home: Some(home.to_path_buf()),
}
}
#[test]
fn discovers_project_with_all_fields() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let yaml =
"id: myapp\nname: MyApp\ndescription: Test app\ntags: [a, b]\nenable_bundles: [base]\n";
write_project_file(temp_dir.path(), yaml);
let env = env_in(temp_dir.path(), temp_dir.path());
let project = discover_project(&env).expect("discover");
assert_eq!(project.id, "myapp");
assert_eq!(project.name, "MyApp");
assert_eq!(project.description, Some("Test app".to_string()));
assert_eq!(project.tags, vec!["a", "b"]);
assert_eq!(project.enable_bundles, vec!["base"]);
assert!(project.unknown_fields.is_empty());
}
#[test]
fn empty_file_uses_defaults() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
write_project_file(temp_dir.path(), "");
let env = env_in(temp_dir.path(), temp_dir.path());
let project = discover_project(&env).expect("discover");
let basename = temp_dir.path().file_name().unwrap().to_string_lossy();
assert_eq!(project.id, basename.as_ref());
assert_eq!(project.name, basename.as_ref());
assert_eq!(project.description, None);
assert!(project.tags.is_empty());
assert!(project.enable_bundles.is_empty());
}
#[test]
fn walks_upward_to_find_marker() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let root = temp_dir.path();
let subdir = root.join("a").join("b");
std::fs::create_dir_all(&subdir).expect("mkdir");
write_project_file(root, "id: found\n");
let env = env_in(&subdir, root);
let project = discover_project(&env).expect("discover");
assert_eq!(project.id, "found");
assert_eq!(project.root, root);
}
#[test]
fn walk_stops_at_home_boundary() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let above_home = temp_dir.path();
let home = above_home.join("home");
let workdir = home.join("project");
std::fs::create_dir_all(&workdir).expect("mkdir");
write_project_file(above_home, "id: hostile\n");
let env = env_in(&workdir, &home);
assert!(
discover_project(&env).is_none(),
"marker above $HOME must not activate"
);
}
#[test]
fn walk_finds_marker_at_home() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let home = temp_dir.path();
let workdir = home.join("project");
std::fs::create_dir_all(&workdir).expect("mkdir");
write_project_file(home, "id: home-project\n");
let env = env_in(&workdir, home);
let project = discover_project(&env).expect("discover");
assert_eq!(project.id, "home-project");
assert_eq!(project.root, home);
}
#[test]
fn no_walk_above_cwd_when_home_unknown() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let root = temp_dir.path();
let subdir = root.join("sub");
std::fs::create_dir_all(&subdir).expect("mkdir");
write_project_file(root, "id: parent\n");
let env = Env {
hostname: String::new(),
user: String::new(),
cwd: subdir.to_string_lossy().to_string(),
gateway_mac: None,
home: None,
};
assert!(
discover_project(&env).is_none(),
"without HOME, walk must not ascend"
);
}
#[test]
fn returns_none_when_no_marker_found() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let env = env_in(temp_dir.path(), temp_dir.path());
let project = discover_project(&env);
assert!(project.is_none());
}
#[test]
fn malformed_yaml_uses_defaults() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
write_project_file(temp_dir.path(), "not: [valid: yaml");
let env = env_in(temp_dir.path(), temp_dir.path());
let project = discover_project(&env).expect("discover");
let basename = temp_dir.path().file_name().unwrap().to_string_lossy();
assert_eq!(project.id, basename.as_ref());
assert_eq!(project.name, basename.as_ref());
}
#[test]
fn long_description_is_truncated() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let huge = "a".repeat(super::MAX_DESCRIPTION_BYTES + 500);
write_project_file(temp_dir.path(), &format!("description: \"{huge}\"\n"));
let env = env_in(temp_dir.path(), temp_dir.path());
let project = discover_project(&env).expect("discover");
let desc = project.description.expect("description");
assert!(
desc.len() <= super::MAX_DESCRIPTION_BYTES,
"description must be capped"
);
}
#[test]
fn captures_unknown_fields() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
write_project_file(
temp_dir.path(),
"id: test\nunknown_field: value\nanother: 42\n",
);
let env = env_in(temp_dir.path(), temp_dir.path());
let project = discover_project(&env).expect("discover");
assert_eq!(project.unknown_fields.len(), 2);
assert!(
project
.unknown_fields
.contains(&"unknown_field".to_string())
);
assert!(project.unknown_fields.contains(&"another".to_string()));
}
proptest! {
#[test]
fn discover_arbitrary_path_never_panics(cwd in r"/[a-z/]*") {
let env = Env {
hostname: String::new(),
user: String::new(),
cwd,
gateway_mac: None,
home: None,
};
let _ = discover_project(&env);
}
#[test]
fn malformed_yaml_never_panics(body in r"\PC*") {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
write_project_file(temp_dir.path(), &body);
let env = env_in(temp_dir.path(), temp_dir.path());
let _ = discover_project(&env);
}
#[test]
fn unicode_safe_basename_derivation(
name_part in r"[^\x00/\.]|[^\x00/][^\x00/]*[^\x00/.]"
) {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let root = temp_dir.path();
let sub = root.join(&name_part);
prop_assume!(std::fs::create_dir_all(&sub).is_ok());
write_project_file(&sub, "");
let env = env_in(&sub, root);
let project = discover_project(&env).expect("discover");
prop_assert!(!project.id.is_empty());
prop_assert!(!project.name.is_empty());
prop_assert_eq!(project.id, name_part.clone());
prop_assert_eq!(project.name, name_part);
}
#[test]
fn walk_terminates_at_home_boundary(
depth in 1..32usize,
) {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let root = temp_dir.path();
let mut deep_path = root.to_path_buf();
for i in 0..depth {
deep_path.push(format!("d{i}"));
}
let _ = std::fs::create_dir_all(&deep_path);
write_project_file(root, "id: root-marker\n");
let env = env_in(&deep_path, root);
let project = discover_project(&env).expect("discover at depth");
prop_assert_eq!(project.id, "root-marker");
prop_assert_eq!(project.root, root);
let temp_dir2 = tempfile::TempDir::new().expect("tempdir2");
let above_home = temp_dir2.path();
let home = above_home.join("home");
let mut deep_work = home.to_path_buf();
for i in 0..depth {
deep_work.push(format!("w{i}"));
}
let _ = std::fs::create_dir_all(&deep_work);
write_project_file(above_home, "id: hostile\n");
let env2 = env_in(&deep_work, &home);
let result = discover_project(&env2);
prop_assert!(result.is_none(), "hostile marker above home must not activate");
}
#[test]
fn project_file_unknown_fields_filtering(
unknown_count in 0..10usize,
known_id in "[a-z0-9]+",
) {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let mut yaml = format!("id: {}\n", known_id);
yaml.push_str("name: TestName\n");
yaml.push_str("tags: [a, b, c]\n");
for i in 0..unknown_count {
yaml.push_str(&format!("field_{}: value_{}\n", i, i));
}
write_project_file(temp_dir.path(), &yaml);
let env = env_in(temp_dir.path(), temp_dir.path());
let project = discover_project(&env).expect("discover");
prop_assert_eq!(project.id, known_id);
prop_assert_eq!(project.name, "TestName");
prop_assert_eq!(project.tags, vec!["a", "b", "c"]);
prop_assert_eq!(
project.unknown_fields.len(),
unknown_count,
"all unknown fields must be captured"
);
for uf in &project.unknown_fields {
prop_assert!(!matches!(
uf.as_str(),
"id" | "name" | "description" | "tags" | "enable_bundles"
));
}
}
}
}