OXYGENHUB

Coverage / crates/spectra-core/src/projects.rs 98.1%

106 of 108 lines run, 35 of 47 functions · run #7 on main · run missed

1
//! Projects (spec §23.1): checkouts the agents work in, with the
2
//! repository id and remote read from `.oxy/`, and dependencies read from
3
//! `.oxymodules`, matched to other registered projects by remote URL.
4
5
use crate::event::Event;
6
use anyhow::{Context, Result};
7
use serde_json::Value as Json;
8
use sqlx::SqliteConnection;
9
use std::path::Path;
10
11
/// What a checkout says about itself.
12
#[derive(Clone, Debug, Default, PartialEq, Eq)]
13
pub struct Checkout {
14
    pub repo_id: Option<String>,
15
    pub remote: Option<String>,
16
    /// The URLs of the subrepos it pins (`.oxymodules`).
17
    pub modules: Vec<String>,
18
}
19
20
/// Read `.oxy/id`, the remote (`origin`, else the first) and `.oxymodules`.
218×
pub fn inspect(path: &Path) -> Result<Checkout> {
228×
    if !path.is_dir() {
230×
        anyhow::bail!("{} is not a directory", path.display());
248×
    }
258×
    let oxy = path.join(".oxy");
268×
    let repo_id = std::fs::read_to_string(oxy.join("id")).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
278×
    let mut remotes: Vec<(String, String)> = Vec::new();
288×
    if let Ok(text) = std::fs::read_to_string(oxy.join("config")) {
298×
        for line in text.lines() {
308×
            if let Some((k, v)) = line.split_once('=') {
318×
                let k = k.trim();
328×
                if let Some(name) = k.strip_prefix("remote.").and_then(|r| r.strip_suffix(".url")) {
338×
                    remotes.push((name.to_string(), v.trim().trim_matches('"').to_string()));
348×
                }
350×
            }
36
        }
371×
    }
388×
    let remote = remotes.iter().find(|(n, _)| n == "origin").or(remotes.first()).map(|(_, u)| u.clone());
398×
    let modules = std::fs::read_to_string(path.join(".oxymodules"))
408×
        .map(|t| {
417×
            t.lines()
429×
                .filter_map(|l| l.trim().strip_prefix("url").map(|r| r.trim_start().trim_start_matches('=').trim().to_string()))
437×
                .collect()
447×
        })
458×
        .unwrap_or_default();
468×
    Ok(Checkout { repo_id, remote, modules })
478×
}
48
49
/// A project's name: the directory's, lower-cased, made valid.
506×
pub fn default_name(path: &Path) -> Result<String> {
516×
    let base = path.canonicalize()?.file_name().and_then(|n| n.to_str()).map(str::to_lowercase).context("the path has no name")?;
5242×
    Ok(base.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect())
536×
}
54
555×
fn same_repo(a: &str, b: &str) -> bool {
5610×
    let n = |s: &str| s.trim_end_matches('/').trim_end_matches(".git").to_lowercase();
575×
    n(a) == n(b)
585×
}
59
60
/// Apply `project.added` to the tables, deriving relations both ways.
6141961×
pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> {
6241961×
    if e.kind != "project.added" {
6338426×
        return Ok(());
643535×
    }
653535×
    let p = e.json();
663604×
    let s = |k: &str| p.get(k).and_then(Json::as_str).map(String::from);
673535×
    let Some(name) = s("name") else { return Ok(()) };
6823×
    let modules: Vec<String> = p
6923×
        .get("modules")
7023×
        .and_then(Json::as_array)
7123×
        .map(|a| a.iter().filter_map(|m| m.as_str().map(String::from)).collect())
7223×
        .unwrap_or_default();
7323×
    let remote = s("remote");
7423×
    sqlx::query("INSERT OR REPLACE INTO projects (name, path, repo_id, remote, modules, seq) VALUES (?, ?, ?, ?, ?, ?)")
7523×
        .bind(&name)
7623×
        .bind(s("path").unwrap_or_default())
7723×
        .bind(s("repo_id"))
7823×
        .bind(&remote)
7923×
        .bind(serde_json::to_string(&modules).unwrap_or_default())
8023×
        .bind(e.seq as i64)
8123×
        .execute(&mut *conn)
8223×
        .await?;
8323×
    let others: Vec<(String, Option<String>, String)> =
8423×
        sqlx::query_as("SELECT name, remote, modules FROM projects WHERE name != ?").bind(&name).fetch_all(&mut *conn).await?;
8523×
    for (other, other_remote, other_modules) in others {
868×
        if other_remote.as_deref().is_some_and(|r| modules.iter().any(|m| same_repo(m, r))) {
872×
            sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)")
882×
                .bind(&name)
892×
                .bind(&other)
902×
                .execute(&mut *conn)
912×
                .await?;
926×
        }
938×
        let theirs: Vec<String> = serde_json::from_str(&other_modules).unwrap_or_default();
948×
        if remote.as_deref().is_some_and(|r| theirs.iter().any(|m| same_repo(m, r))) {
952×
            sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)")
962×
                .bind(&other)
972×
                .bind(&name)
982×
                .execute(&mut *conn)
992×
                .await?;
1006×
        }
101
    }
10223×
    Ok(())
10341961×
}
104
105
#[cfg(test)]
106
mod tests {
107
    use super::*;
108
109
    #[test]
1101×
    fn a_checkout_reports_its_id_remote_and_subrepos() {
1111×
        let dir = tempfile::tempdir().unwrap();
1121×
        std::fs::create_dir_all(dir.path().join(".oxy")).unwrap();
1131×
        std::fs::write(dir.path().join(".oxy/id"), "97e6").unwrap();
1141×
        std::fs::write(
1151×
            dir.path().join(".oxy/config"),
116
            "remote.earth.url = \"https://hub/a/hub\"\nremote.origin.url = \"https://hub/a/x\"\n",
117
        )
1181×
        .unwrap();
1191×
        std::fs::write(dir.path().join(".oxymodules"), "[deps/flow]\nurl = https://hub/a/flow\nbookmark = main\n").unwrap();
1201×
        let c = inspect(dir.path()).unwrap();
1211×
        assert_eq!(
122
            c,
1231×
            Checkout { repo_id: Some("97e6".into()), remote: Some("https://hub/a/x".into()), modules: vec!["https://hub/a/flow".into()] }
124
        );
1251×
        assert!(same_repo("https://hub/a/flow/", "https://HUB/a/flow.git"));
1261×
    }
127
}