semora-cli 0.7.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! `ls`, `show`, `get`, `tag`, `rm`, `open`.

use std::io::Write;
use std::path::PathBuf;

use anyhow::Context;
use semora_proto::PatchDoc;

use crate::client::{Client, ListQuery};
use crate::{Ctx, checkout, docref, exit, output, project_link};

pub async fn ls(
    ctx: &Ctx,
    query: Option<String>,
    tag: Option<String>,
    kind: Option<String>,
    project: Option<String>,
    limit: u32,
) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let list = client
        .list(&ListQuery {
            q: query,
            tag,
            kind,
            project: project.clone(),
            limit: Some(limit),
            ..Default::default()
        })
        .await
        .map_err(|e| match &project {
            Some(p) if exit::is_not_found(&e) => {
                exit::not_found(format!("no project `{p}` on {}", client.origin()))
            }
            _ => e,
        })?;
    // An old server ignores `?project=` and lists everything; don't pass that off as the project.
    if project.is_some() && list.docs.iter().any(|d| d.project.is_none()) {
        anyhow::bail!(
            "this server has no projects; it may be older than this CLI. Update the server."
        );
    }
    if ctx.json {
        output::json(&list);
        return Ok(exit::OK);
    }
    if list.docs.is_empty() {
        println!("No documents.");
        return Ok(exit::OK);
    }
    for d in &list.docs {
        let tags = if d.tags.is_empty() {
            String::new()
        } else {
            format!("  [{}]", d.tags.join(", "))
        };
        let at = match (&d.project, &d.path) {
            (Some(p), Some(path)) => format!("  {p}:{path}"),
            _ => String::new(),
        };
        println!(
            "{}  r{:<3} {:<6} {}  {}{}{}",
            d.id,
            d.rev,
            d.kind.as_str(),
            output::ts(d.updated_at),
            d.title,
            tags,
            at
        );
    }
    if list.next_cursor.is_some() {
        println!("… more; raise --limit or narrow with -q/--tag");
    }
    Ok(exit::OK)
}

pub async fn show(ctx: &Ctx, doc: &str) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let id = docref::resolve(&client, doc).await?;
    let d = client.doc(&id).await?;
    if ctx.json {
        output::json(&d);
        return Ok(exit::OK);
    }
    let s = &d.summary;
    println!(
        "{}  ({}, r{}, {} revisions kept)",
        s.title,
        s.kind.as_str(),
        s.rev,
        s.revision_count
    );
    println!("  url    {}", s.url);
    println!("  ws     {}", d.ws_url);
    println!("  entry  {}", d.entry_path);
    if let (Some(p), Some(path)) = (&s.project, &s.path) {
        println!("  proj   {p}");
        println!("  path   {path}");
        if let Some(u) = &s.path_url {
            println!("  link   {u}");
        }
    }
    if !s.tags.is_empty() {
        println!("  tags   {}", s.tags.join(", "));
    }
    println!("files:");
    for f in &d.files {
        println!("  {:<40} {:>9}  {}", f.path, output::size(f.size), f.mime);
    }
    println!("revisions:");
    for r in &d.revisions {
        // The pusher's name, and the key's name when the server shows it (Editors and the owner).
        let by = match (&r.pushed_by, r.created_by.as_deref()) {
            (Some(p), Some(key)) => format!("  by {} ({key})", output::clean_name(&p.name)),
            (Some(p), None) => format!("  by {}", output::clean_name(&p.name)),
            (None, Some(key)) => format!("  by {key}"),
            (None, None) => String::new(),
        };
        let msg = r
            .message
            .as_deref()
            .map(|m| format!("  {m}"))
            .unwrap_or_default();
        let from = r
            .origin
            .as_deref()
            .map(|o| format!("  from {o}"))
            .unwrap_or_default();
        println!(
            "  r{:<4} {}{}{}{}",
            r.rev,
            output::ts(r.created_at),
            msg,
            from,
            by
        );
    }
    Ok(exit::OK)
}

pub async fn get(
    ctx: &Ctx,
    doc: &str,
    path: Option<String>,
    rev: Option<i64>,
    out: Option<PathBuf>,
) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let id = docref::resolve(&client, doc).await?;
    let bytes = client.raw(&id, path.as_deref(), rev).await?;
    match out {
        Some(p) => {
            std::fs::write(&p, &bytes).with_context(|| format!("writing {}", p.display()))?
        }
        None => {
            let mut stdout = std::io::stdout().lock();
            stdout.write_all(&bytes)?;
            stdout.flush()?;
        }
    }
    Ok(exit::OK)
}

/// `+a -b c` → add a and c, remove b.
pub fn tag_changes(changes: &[String]) -> PatchDoc {
    let mut p = PatchDoc::default();
    for c in changes {
        for part in c.split(',').map(str::trim).filter(|s| !s.is_empty()) {
            if let Some(t) = part.strip_prefix('-') {
                p.remove_tags.push(t.to_string());
            } else {
                p.add_tags.push(part.trim_start_matches('+').to_string());
            }
        }
    }
    p
}

pub async fn tag(ctx: &Ctx, doc: &str, changes: &[String]) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let id = docref::resolve(&client, doc).await?;
    let s = client.patch(&id, &tag_changes(changes)).await?;
    if ctx.json {
        output::json(&s);
    } else {
        println!(
            "{}: {}",
            s.id,
            if s.tags.is_empty() {
                "(no tags)".to_string()
            } else {
                s.tags.join(", ")
            }
        );
    }
    Ok(exit::OK)
}

pub async fn rm(ctx: &Ctx, doc: &str) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let id = docref::resolve(&client, doc).await?;
    client.delete(&id).await?;
    forget_here(&client, &id);
    if !ctx.json {
        println!("Deleted {id} (purged after 7 days)");
    }
    Ok(exit::OK)
}

/// Drops the current folder's record of a document deleted from it, so the next push of its
/// path makes it again instead of being refused as a push over someone else's delete.
fn forget_here(client: &Client, id: &str) {
    let Ok(cwd) = std::env::current_dir() else {
        return;
    };
    let Ok(Some(link)) = project_link::find(&cwd) else {
        return;
    };
    let held = checkout::load(&link).is_ok_and(|r| r.values().any(|rec| rec.id == id));
    if link.server != client.origin() || !held {
        return;
    }
    if let Err(e) = checkout::update(&link, |r| r.retain(|_, rec| rec.id != id)) {
        output::warn(format!("could not update this folder's record: {e:#}"));
    }
}

pub async fn open(ctx: &Ctx, doc: &str) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let id = docref::resolve(&client, doc).await?;
    let d = client.doc(&id).await?;
    let url = web_link(&d.summary.url)?;
    println!("{url}");
    if ctx.settings.open_browser
        && let Err(e) = open::that(&url)
    {
        output::warn(format!("could not open a browser: {e}"));
    }
    Ok(exit::OK)
}

/// The server's page address, only as an http(s) link. The system would run a file path or
/// hand another scheme to its app, so a hostile server could start a program.
fn web_link(url: &str) -> anyhow::Result<String> {
    match url::Url::parse(url) {
        Ok(u) if matches!(u.scheme(), "http" | "https") => Ok(u.into()),
        _ => anyhow::bail!("the server's page address is not a web link: {url:?}"),
    }
}

#[cfg(test)]
mod tests {
    use super::tag_changes;

    #[test]
    fn parses_tag_changes() {
        let p = tag_changes(&["+a".into(), "-b".into(), "c,d".into()]);
        assert_eq!(p.add_tags, vec!["a", "c", "d"]);
        assert_eq!(p.remove_tags, vec!["b"]);
    }
}