//! Projects (spec ยง23.1): checkouts the agents work in, with the //! repository id and remote read from `.oxy/`, and dependencies read from //! `.oxymodules`, matched to other registered projects by remote URL. use crate::event::Event; use anyhow::{Context, Result}; use serde_json::Value as Json; use sqlx::SqliteConnection; use std::path::Path; /// What a checkout says about itself. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Checkout { pub repo_id: Option, pub remote: Option, /// The URLs of the subrepos it pins (`.oxymodules`). pub modules: Vec, } /// Read `.oxy/id`, the remote (`origin`, else the first) and `.oxymodules`. pub fn inspect(path: &Path) -> Result { if !path.is_dir() { anyhow::bail!("{} is not a directory", path.display()); } let oxy = path.join(".oxy"); let repo_id = std::fs::read_to_string(oxy.join("id")).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); let mut remotes: Vec<(String, String)> = Vec::new(); if let Ok(text) = std::fs::read_to_string(oxy.join("config")) { for line in text.lines() { if let Some((k, v)) = line.split_once('=') { let k = k.trim(); if let Some(name) = k.strip_prefix("remote.").and_then(|r| r.strip_suffix(".url")) { remotes.push((name.to_string(), v.trim().trim_matches('"').to_string())); } } } } let remote = remotes.iter().find(|(n, _)| n == "origin").or(remotes.first()).map(|(_, u)| u.clone()); let modules = std::fs::read_to_string(path.join(".oxymodules")) .map(|t| { t.lines() .filter_map(|l| l.trim().strip_prefix("url").map(|r| r.trim_start().trim_start_matches('=').trim().to_string())) .collect() }) .unwrap_or_default(); Ok(Checkout { repo_id, remote, modules }) } /// A project's name: the directory's, lower-cased, made valid. pub fn default_name(path: &Path) -> Result { let base = path.canonicalize()?.file_name().and_then(|n| n.to_str()).map(str::to_lowercase).context("the path has no name")?; Ok(base.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect()) } fn same_repo(a: &str, b: &str) -> bool { let n = |s: &str| s.trim_end_matches('/').trim_end_matches(".git").to_lowercase(); n(a) == n(b) } /// Apply `project.added` to the tables, deriving relations both ways. pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> { if e.kind != "project.added" { return Ok(()); } let p = e.json(); let s = |k: &str| p.get(k).and_then(Json::as_str).map(String::from); let Some(name) = s("name") else { return Ok(()) }; let modules: Vec = p .get("modules") .and_then(Json::as_array) .map(|a| a.iter().filter_map(|m| m.as_str().map(String::from)).collect()) .unwrap_or_default(); let remote = s("remote"); sqlx::query("INSERT OR REPLACE INTO projects (name, path, repo_id, remote, modules, seq) VALUES (?, ?, ?, ?, ?, ?)") .bind(&name) .bind(s("path").unwrap_or_default()) .bind(s("repo_id")) .bind(&remote) .bind(serde_json::to_string(&modules).unwrap_or_default()) .bind(e.seq as i64) .execute(&mut *conn) .await?; let others: Vec<(String, Option, String)> = sqlx::query_as("SELECT name, remote, modules FROM projects WHERE name != ?").bind(&name).fetch_all(&mut *conn).await?; for (other, other_remote, other_modules) in others { if other_remote.as_deref().is_some_and(|r| modules.iter().any(|m| same_repo(m, r))) { sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)") .bind(&name) .bind(&other) .execute(&mut *conn) .await?; } let theirs: Vec = serde_json::from_str(&other_modules).unwrap_or_default(); if remote.as_deref().is_some_and(|r| theirs.iter().any(|m| same_repo(m, r))) { sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)") .bind(&other) .bind(&name) .execute(&mut *conn) .await?; } } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn a_checkout_reports_its_id_remote_and_subrepos() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join(".oxy")).unwrap(); std::fs::write(dir.path().join(".oxy/id"), "97e6").unwrap(); std::fs::write( dir.path().join(".oxy/config"), "remote.earth.url = \"https://hub/a/hub\"\nremote.origin.url = \"https://hub/a/x\"\n", ) .unwrap(); std::fs::write(dir.path().join(".oxymodules"), "[deps/flow]\nurl = https://hub/a/flow\nbookmark = main\n").unwrap(); let c = inspect(dir.path()).unwrap(); assert_eq!( c, Checkout { repo_id: Some("97e6".into()), remote: Some("https://hub/a/x".into()), modules: vec!["https://hub/a/flow".into()] } ); assert!(same_repo("https://hub/a/flow/", "https://HUB/a/flow.git")); } }