semora-cli 0.3.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::ListQuery;
use crate::{Ctx, docref, exit, output};

pub async fn ls(
    ctx: &Ctx,
    query: Option<String>,
    tag: Option<String>,
    kind: Option<String>,
    limit: u32,
) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let list = client
        .list(&ListQuery {
            q: query,
            tag,
            kind,
            limit: Some(limit),
            cursor: None,
        })
        .await?;
    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(", "))
        };
        println!(
            "{}  r{:<3} {:<6} {}  {}{}",
            d.id,
            d.rev,
            d.kind.as_str(),
            output::ts(d.updated_at),
            d.title,
            tags
        );
    }
    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::parse(doc)?;
    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 !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 {
        let by = r
            .created_by
            .as_deref()
            .map(|b| format!("  by {b}"))
            .unwrap_or_default();
        let msg = r
            .message
            .as_deref()
            .map(|m| format!("  {m}"))
            .unwrap_or_default();
        println!("  r{:<4} {}{}{}", r.rev, output::ts(r.created_at), msg, 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::parse(doc)?;
    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::parse(doc)?;
    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::parse(doc)?;
    client.delete(&id).await?;
    if !ctx.json {
        println!("Deleted {id} (purged after 7 days)");
    }
    Ok(exit::OK)
}

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

#[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"]);
    }
}