OXYGENHUB
# 01320eefd505 spectra / crates / spectra-core / src / projects.rs
5.2 KiB · Rust · last changed Projects with relations from .oxymodules, presence and sessions; projections skip malformed payloads (#5) 2 days ago source blame history raw
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`.
21
pub fn inspect(path: &Path) -> Result<Checkout> {
22
    if !path.is_dir() {
23
        anyhow::bail!("{} is not a directory", path.display());
24
    }
25
    let oxy = path.join(".oxy");
26
    let repo_id = std::fs::read_to_string(oxy.join("id")).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
27
    let mut remotes: Vec<(String, String)> = Vec::new();
28
    if let Ok(text) = std::fs::read_to_string(oxy.join("config")) {
29
        for line in text.lines() {
30
            if let Some((k, v)) = line.split_once('=') {
31
                let k = k.trim();
32
                if let Some(name) = k.strip_prefix("remote.").and_then(|r| r.strip_suffix(".url")) {
33
                    remotes.push((name.to_string(), v.trim().trim_matches('"').to_string()));
34
                }
35
            }
36
        }
37
    }
38
    let remote = remotes.iter().find(|(n, _)| n == "origin").or(remotes.first()).map(|(_, u)| u.clone());
39
    let modules = std::fs::read_to_string(path.join(".oxymodules"))
40
        .map(|t| {
41
            t.lines()
42
                .filter_map(|l| l.trim().strip_prefix("url").map(|r| r.trim_start().trim_start_matches('=').trim().to_string()))
43
                .collect()
44
        })
45
        .unwrap_or_default();
46
    Ok(Checkout { repo_id, remote, modules })
47
}
48
49
/// A project's name: the directory's, lower-cased, made valid.
50
pub fn default_name(path: &Path) -> Result<String> {
51
    let base = path.canonicalize()?.file_name().and_then(|n| n.to_str()).map(str::to_lowercase).context("the path has no name")?;
52
    Ok(base.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect())
53
}
54
55
fn same_repo(a: &str, b: &str) -> bool {
56
    let n = |s: &str| s.trim_end_matches('/').trim_end_matches(".git").to_lowercase();
57
    n(a) == n(b)
58
}
59
60
/// Apply `project.added` to the tables, deriving relations both ways.
61
pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> {
62
    if e.kind != "project.added" {
63
        return Ok(());
64
    }
65
    let p = e.json();
66
    let s = |k: &str| p.get(k).and_then(Json::as_str).map(String::from);
67
    let Some(name) = s("name") else { return Ok(()) };
68
    let modules: Vec<String> = p
69
        .get("modules")
70
        .and_then(Json::as_array)
71
        .map(|a| a.iter().filter_map(|m| m.as_str().map(String::from)).collect())
72
        .unwrap_or_default();
73
    let remote = s("remote");
74
    sqlx::query("INSERT OR REPLACE INTO projects (name, path, repo_id, remote, modules, seq) VALUES (?, ?, ?, ?, ?, ?)")
75
        .bind(&name)
76
        .bind(s("path").unwrap_or_default())
77
        .bind(s("repo_id"))
78
        .bind(&remote)
79
        .bind(serde_json::to_string(&modules).unwrap_or_default())
80
        .bind(e.seq as i64)
81
        .execute(&mut *conn)
82
        .await?;
83
    let others: Vec<(String, Option<String>, String)> =
84
        sqlx::query_as("SELECT name, remote, modules FROM projects WHERE name != ?").bind(&name).fetch_all(&mut *conn).await?;
85
    for (other, other_remote, other_modules) in others {
86
        if other_remote.as_deref().is_some_and(|r| modules.iter().any(|m| same_repo(m, r))) {
87
            sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)")
88
                .bind(&name)
89
                .bind(&other)
90
                .execute(&mut *conn)
91
                .await?;
92
        }
93
        let theirs: Vec<String> = serde_json::from_str(&other_modules).unwrap_or_default();
94
        if remote.as_deref().is_some_and(|r| theirs.iter().any(|m| same_repo(m, r))) {
95
            sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)")
96
                .bind(&other)
97
                .bind(&name)
98
                .execute(&mut *conn)
99
                .await?;
100
        }
101
    }
102
    Ok(())
103
}
104
105
#[cfg(test)]
106
mod tests {
107
    use super::*;
108
109
    #[test]
110
    fn a_checkout_reports_its_id_remote_and_subrepos() {
111
        let dir = tempfile::tempdir().unwrap();
112
        std::fs::create_dir_all(dir.path().join(".oxy")).unwrap();
113
        std::fs::write(dir.path().join(".oxy/id"), "97e6").unwrap();
114
        std::fs::write(
115
            dir.path().join(".oxy/config"),
116
            "remote.earth.url = \"https://hub/a/hub\"\nremote.origin.url = \"https://hub/a/x\"\n",
117
        )
118
        .unwrap();
119
        std::fs::write(dir.path().join(".oxymodules"), "[deps/flow]\nurl = https://hub/a/flow\nbookmark = main\n").unwrap();
120
        let c = inspect(dir.path()).unwrap();
121
        assert_eq!(
122
            c,
123
            Checkout { repo_id: Some("97e6".into()), remote: Some("https://hub/a/x".into()), modules: vec!["https://hub/a/flow".into()] }
124
        );
125
        assert!(same_repo("https://hub/a/flow/", "https://HUB/a/flow.git"));
126
    }
127
}