OXYGENHUB

Coverage / src/cli/brief.rs 53.8%

56 of 104 lines run, 15 of 42 functions · run #28 on main · run missed

1
//! `spectra brief`: what the owner tells the agents (spec §12.1), an
2
//! overall brief and one per project, each a signed `brief.set` event.
3
4
use super::identity::owner_home;
5
use super::{call, ui, Ctx};
6
use anyhow::{bail, Context, Result};
7
use spectra_core::keys::KeyPair;
8
use spectra_core::ledger::Ledger;
9
use spectra_core::principals::signed_event;
10
use std::path::PathBuf;
11
125×
fn label(project: Option<&str>) -> String {
135×
    project.map(|p| format!("{p}'s brief")).unwrap_or_else(|| "the overall brief".into())
145×
}
15
16
/// A layer's current text: from the daemon when one serves, else the ledger.
171×
async fn current(ctx: &Ctx, project: Option<&str>) -> Result<Option<String>> {
181×
    let home = owner_home(ctx, None);
191×
    if let Some(c) = ctx.owner_client(&home).await? {
201×
        let v = call(&c, "GET", "/api/v1/briefs", None).await?;
211×
        return Ok(match project {
220×
            None => v["overall"].as_str().map(String::from),
231×
            Some(p) => v["projects"]
241×
                .as_array()
252×
                .and_then(|a| a.iter().find(|x| x["project"] == p))
261×
                .and_then(|x| x["text"].as_str())
271×
                .map(String::from),
28
        });
290×
    }
300×
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
310×
    spectra_core::rules::layer(ledger.pool(), project).await
321×
}
33
341×
pub async fn show(ctx: &Ctx, project: Option<String>, as_json: bool) -> Result<i32> {
351×
    let text = current(ctx, project.as_deref()).await?;
361×
    if as_json {
371×
        println!("{}", serde_json::json!({ "project": project, "text": text }));
381×
        return Ok(0);
390×
    }
400×
    println!("{}", ui::section(label(project.as_deref())));
410×
    match text {
420×
        Some(t) => println!("{}", t.trim_end()),
430×
        None => println!("{} {}", ui::note(), ui::dim("not set: spectra brief set [--project P] \"…\", or spectra brief edit")),
44
    }
450×
    Ok(0)
461×
}
47
486×
pub async fn set(ctx: &Ctx, project: Option<String>, text: String, owner_home_flag: Option<PathBuf>) -> Result<i32> {
496×
    spectra_core::rules::check(&text)?;
505×
    let home = owner_home(ctx, owner_home_flag);
515×
    let w = ctx.owner_writer(&home).await?;
525×
    if let Some(p) = &project {
533×
        let known = match (w.client(), w.ledger()) {
543×
            (Some(c), _) => {
553×
                call(c, "GET", "/api/v1/projects", None).await?.as_array().is_some_and(|l| l.iter().any(|x| x["name"] == p.as_str()))
56
            }
570×
            (None, Some(l)) => sqlx::query_scalar::<_, String>("SELECT name FROM projects WHERE name = ?")
580×
                .bind(p)
590×
                .fetch_optional(l.pool())
600×
                .await?
610×
                .is_some(),
620×
            (None, None) => false,
63
        };
643×
        if !known {
650×
            bail!("no project named {p} (spectra project list)");
663×
        }
672×
    }
685×
    let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect();
695×
    let owner = KeyPair::load_home(&home)?;
705×
    let mut e = signed_event(&owner, "human", "brief.set", serde_json::json!({ "project": project, "text": text }));
715×
    e.project = project.clone();
725×
    events.push(e);
735×
    w.append(events).await?;
745×
    let verb = if text.trim().is_empty() { "Cleared" } else { "Set" };
755×
    println!("{} {}", ui::done(verb), ui::subject(label(project.as_deref())));
765×
    println!("  {} {}", ui::note(), ui::dim("agents are told on their next call that their brief changed"));
775×
    Ok(0)
786×
}
79
80
/// Read the text from an argument, a file, or `-` for standard input.
816×
pub fn text_of(text: Option<String>, file: Option<PathBuf>) -> Result<String> {
826×
    match (text, file) {
836×
        (Some(t), None) => Ok(t),
840×
        (None, Some(f)) if f.as_os_str() == "-" => {
850×
            let mut s = String::new();
860×
            std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
870×
            Ok(s)
88
        }
890×
        (None, Some(f)) => std::fs::read_to_string(&f).with_context(|| format!("reading {}", f.display())),
900×
        (Some(_), Some(_)) => bail!("give the text or --file, not both"),
910×
        (None, None) => bail!("give the text, or --file FILE (- for standard input), or use spectra brief edit"),
92
    }
936×
}
94
95
/// `spectra brief edit`: the current text in `$VISUAL` or `$EDITOR`.
960×
pub async fn edit(ctx: &Ctx, project: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> {
970×
    let before = current(ctx, project.as_deref()).await?.unwrap_or_default();
980×
    let dir = tempfile::tempdir()?;
990×
    let path = dir.path().join(format!("{}.md", project.as_deref().unwrap_or("overall")));
1000×
    std::fs::write(&path, &before)?;
1010×
    let editor = std::env::var("VISUAL").or_else(|_| std::env::var("EDITOR")).unwrap_or_else(|_| "nano".into());
1020×
    let mut parts = editor.split_whitespace();
1030×
    let program = parts.next().context("no editor ($VISUAL or $EDITOR)")?;
1040×
    let status = std::process::Command::new(program).args(parts).arg(&path).status().with_context(|| format!("running {editor}"))?;
1050×
    if !status.success() {
1060×
        bail!("{editor} did not finish cleanly: nothing was changed");
1070×
    }
1080×
    let after = std::fs::read_to_string(&path)?;
1090×
    if after == before {
1100×
        println!("{} {}", ui::note(), ui::dim("unchanged"));
1110×
        return Ok(0);
1120×
    }
1130×
    set(ctx, project, after, owner_home_flag).await
1140×
}