OXYGENHUB

Coverage / src/cli/project.rs 77.5%

107 of 138 lines run, 19 of 50 functions · run #28 on main · run missed

1
//! `spectra project`: the work agents share (spec §23.1), any kind of it.
2
3
use super::identity::owner_home;
4
use super::{call, ui, Ctx};
5
use anyhow::{bail, Context, Result};
6
use spectra_core::keys::KeyPair;
7
use spectra_core::ledger::Ledger;
8
use spectra_core::principals::signed_event;
9
use spectra_core::projects;
10
use std::path::{Path, PathBuf};
11
12
pub struct Add {
13
    pub target: String,
14
    pub name: Option<String>,
15
    pub path: Option<PathBuf>,
16
    pub description: String,
17
    pub url: Option<String>,
18
}
19
2025×
pub async fn add(ctx: &Ctx, a: Add, owner_home_flag: Option<PathBuf>) -> Result<i32> {
21
    // A folder that exists is the work's folder; anything else is a name.
2225×
    let as_dir = Path::new(&shellexpand(&a.target)).to_path_buf();
2325×
    let (name, path) = match (&a.path, as_dir.is_dir()) {
24
        (None, true) => {
2521×
            let path = as_dir.canonicalize()?;
2621×
            (a.name.clone().map(Ok).unwrap_or_else(|| projects::default_name(&path))?, Some(path))
27
        }
280×
        (Some(p), _) => {
290×
            let path = p.canonicalize().with_context(|| format!("{} does not exist", p.display()))?;
300×
            (a.name.clone().unwrap_or_else(|| a.target.clone()), Some(path))
31
        }
324×
        (None, false) => (a.name.clone().unwrap_or_else(|| a.target.clone()), None),
33
    };
3425×
    if !super::agent::valid_name(&name) {
351×
        bail!("a project's name is lower-case letters, digits and dashes, starting with a letter or digit (--name)");
3624×
    }
3724×
    let home = owner_home(ctx, owner_home_flag);
3824×
    let w = ctx.owner_writer(&home).await?;
3924×
    let path_s = path.as_ref().map(|p| p.display().to_string());
4024×
    let existing = all(&w).await?;
4124×
    if let Some(e) = existing.iter().find(|p| p["name"] == name.as_str() || (path_s.is_some() && p["path"].as_str() == path_s.as_deref())) {
421×
        bail!("{} is already a project", e["name"].as_str().unwrap_or(""));
4323×
    }
4423×
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
4523×
    let owner = KeyPair::load_home(&home)?;
4623×
    let payload = serde_json::json!({ "name": name, "path": path_s, "description": a.description, "url": a.url });
4723×
    events.push(signed_event(&owner, "human", "project.added", payload).project(name.clone()).subject(name.clone()));
4823×
    events.push(spectra_core::conversation::project_channel_event(&name));
4923×
    w.append(events).await?;
5023×
    println!("{} {}{}", ui::done("Added"), ui::subject(&name), path_s.map(|p| format!("  {}", ui::path(p))).unwrap_or_default());
5123×
    if !a.description.is_empty() {
524×
        println!("{}", ui::kv("about", &a.description));
5319×
    }
5423×
    if let Some(u) = &a.url {
551×
        println!("{}", ui::kv("link", u));
5622×
    }
5723×
    println!(
58
        "  {} {}",
5923×
        ui::note(),
6023×
        ui::dim(format!("agents talk about it in #{name}; link it with spectra project link {name} --depends-on OTHER"))
61
    );
6223×
    Ok(0)
6325×
}
64
65
/// `~/x` as the shell would have it.
6625×
fn shellexpand(s: &str) -> String {
6725×
    match (s.strip_prefix("~/"), std::env::var_os("HOME")) {
680×
        (Some(rest), Some(h)) => Path::new(&h).join(rest).display().to_string(),
6925×
        _ => s.to_string(),
70
    }
7125×
}
72
73
/// `spectra project link P --depends-on Q [--remove]`.
743×
pub async fn link(ctx: &Ctx, project: &str, on: &str, remove: bool, owner_home_flag: Option<PathBuf>) -> Result<i32> {
753×
    if project == on {
760×
        bail!("a project cannot depend on itself");
773×
    }
783×
    let home = owner_home(ctx, owner_home_flag);
793×
    let w = ctx.owner_writer(&home).await?;
803×
    let existing = all(&w).await?;
816×
    for n in [project, on] {
829×
        if !existing.iter().any(|p| p["name"] == n) {
831×
            bail!("no project named {n} (spectra project list)");
845×
        }
85
    }
862×
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
872×
    let owner = KeyPair::load_home(&home)?;
882×
    let kind = if remove { "project.unlinked" } else { "project.linked" };
892×
    events.push(
902×
        signed_event(&owner, "human", kind, serde_json::json!({ "project": project, "depends_on": on })).project(project.to_string()),
91
    );
922×
    w.append(events).await?;
932×
    let verb = if remove { "Unlinked" } else { "Linked" };
942×
    println!("{} {} {} {}", ui::done(verb), ui::subject(project), ui::dim("depends on"), ui::subject(on));
952×
    Ok(0)
963×
}
97
9814×
async fn relations(ledger: &Ledger, name: &str) -> Result<(Vec<String>, Vec<String>)> {
9914×
    let deps = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ? ORDER BY 1")
10014×
        .bind(name)
10114×
        .fetch_all(ledger.pool())
10214×
        .await?;
10314×
    let users = sqlx::query_scalar("SELECT project FROM project_relations WHERE depends_on = ? ORDER BY 1")
10414×
        .bind(name)
10514×
        .fetch_all(ledger.pool())
10614×
        .await?;
10714×
    Ok((deps, users))
10814×
}
109
110
/// Every project with its relations and agents: from the daemon when one
111
/// serves, else from the ledger here.
11227×
async fn all(w: &super::Writer) -> Result<Vec<serde_json::Value>> {
11327×
    match (w.client(), w.ledger()) {
1142×
        (Some(c), _) => Ok(call(c, "GET", "/api/v1/projects", None).await?.as_array().cloned().unwrap_or_default()),
11525×
        (None, Some(l)) => local(l).await,
1160×
        (None, None) => Ok(vec![]),
117
    }
11827×
}
119
12028×
async fn local(ledger: &Ledger) -> Result<Vec<serde_json::Value>> {
12128×
    let rows: Vec<(String, Option<String>, String, Option<String>)> =
12228×
        sqlx::query_as("SELECT name, path, description, url FROM projects ORDER BY name").fetch_all(ledger.pool()).await?;
12328×
    let mut out = Vec::new();
12428×
    for (name, path, description, url) in rows {
12514×
        let (deps, users) = relations(ledger, &name).await?;
12614×
        let agents: Vec<String> = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name")
12714×
            .bind(&name)
12814×
            .fetch_all(ledger.pool())
12914×
            .await?;
13014×
        out.push(serde_json::json!({ "name": name, "path": path, "description": description, "url": url, "depends_on": deps, "depended_on_by": users, "agents": agents }));
131
    }
13228×
    Ok(out)
13328×
}
134
1354×
pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> {
1364×
    let out = match ctx.owner_client(&owner_home(ctx, None)).await? {
1371×
        Some(c) => call(&c, "GET", "/api/v1/projects", None).await?.as_array().cloned().unwrap_or_default(),
1383×
        None => local(&Ledger::open_read_only(&ctx.db()?).await?).await?,
139
    };
1404×
    if json {
1414×
        println!("{}", serde_json::Value::Array(out));
1424×
        return Ok(0);
1430×
    }
1440×
    if out.is_empty() {
1450×
        println!("{} {}", ui::note(), ui::dim("no projects yet: spectra project add NAME-OR-FOLDER"));
1460×
    }
1470×
    for p in out {
1480×
        let list =
1490×
            |k: &str| p[k].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default();
1500×
        println!(
151
            "{} {}  {}",
1520×
            ui::glyph("▸", ">"),
1530×
            ui::bookmark(p["name"].as_str().unwrap_or("")),
1540×
            ui::path(p["path"].as_str().unwrap_or(""))
155
        );
1560×
        if let Some(d) = p["description"].as_str().filter(|d| !d.is_empty()) {
1570×
            println!("{}", ui::kv("about", d));
1580×
        }
1590×
        for (k, label) in [("depends_on", "depends on"), ("depended_on_by", "needed by"), ("agents", "agents")] {
1600×
            let v = list(k);
1610×
            if !v.is_empty() {
1620×
                println!("{}", ui::kv(label, v));
1630×
            }
164
        }
165
    }
1660×
    Ok(0)
1674×
}