semora-cli 0.3.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! `semora push`: publish a file or folder.

use std::path::Path;

use anyhow::{Context, bail};
use semora_proto::PushResponse;
use serde_json::{Map, Value};

use crate::args::PushArgs;
use crate::bundle;
use crate::client::{Client, PushQuery};
use crate::remember::{self, Memo};
use crate::{Ctx, docref, exit, output};

/// What to send for `path`: body, content type, single-file name, warnings.
pub struct Payload {
    pub body: Vec<u8>,
    pub content_type: &'static str,
    pub filename: Option<String>,
}

pub fn payload(path: &Path) -> anyhow::Result<Payload> {
    let meta =
        std::fs::metadata(path).with_context(|| format!("cannot read {}", path.display()))?;
    if meta.is_dir() {
        let packed = bundle::pack_dir(path)?;
        for (p, why) in &packed.skipped {
            output::warn(format!("skipped {p} ({why})"));
        }
        if packed.files.is_empty() {
            bail!("{} has no files Semora can serve", path.display());
        }
        if !packed.files.iter().any(|f| bundle::is_page(f)) {
            bail!("{} has no Markdown or HTML page", path.display());
        }
        return Ok(Payload {
            body: packed.tar,
            content_type: "application/x-tar",
            filename: None,
        });
    }
    let name = path
        .file_name()
        .map(|n| n.to_string_lossy().into_owned())
        .unwrap_or_default();
    let lower = name.to_ascii_lowercase();
    let content_type = if lower.ends_with(".md") || lower.ends_with(".markdown") {
        "text/markdown; charset=utf-8"
    } else if lower.ends_with(".html") || lower.ends_with(".htm") {
        "text/html; charset=utf-8"
    } else {
        bail!("{name}: push a .md or .html file, or a folder");
    };
    let body = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
    if content_type.starts_with("text/markdown") {
        let links = bundle::local_page_links(&String::from_utf8_lossy(&body));
        if !links.is_empty() {
            output::warn(format!(
                "{name} links to local pages ({}); push the folder to publish them together as a bundle",
                links.join(", ")
            ));
        }
    }
    Ok(Payload {
        body,
        content_type,
        filename: Some(name),
    })
}

/// `key=value` pairs; values that parse as JSON scalars keep their type.
pub fn parse_meta(pairs: &[String]) -> anyhow::Result<Option<String>> {
    if pairs.is_empty() {
        return Ok(None);
    }
    let mut m = Map::new();
    for p in pairs {
        let (k, v) = p
            .split_once('=')
            .with_context(|| format!("--meta `{p}` must be key=value"))?;
        let value = match serde_json::from_str::<Value>(v) {
            Ok(j @ (Value::Number(_) | Value::Bool(_) | Value::Null)) => j,
            _ => Value::String(v.to_string()),
        };
        m.insert(k.trim().to_string(), value);
    }
    Ok(Some(Value::Object(m).to_string()))
}

pub fn tags(args_tags: &[String], defaults: &[String]) -> Option<String> {
    let all: Vec<String> = args_tags
        .iter()
        .flat_map(|t| t.split(','))
        .chain(defaults.iter().map(String::as_str))
        .map(str::trim)
        .filter(|t| !t.is_empty())
        .map(str::to_string)
        .collect();
    (!all.is_empty()).then(|| all.join(","))
}

/// Pushes `path`, replacing the explicit or remembered document. Returns the response.
pub async fn push_path(
    ctx: &Ctx,
    client: &Client,
    path: &Path,
    explicit_id: Option<&str>,
    force_new: bool,
    query: &PushQuery,
) -> anyhow::Result<PushResponse> {
    let p = payload(path)?;
    let mut q = query.clone();
    q.filename = p.filename.clone();
    let server = client.origin();
    let config_dir = &ctx.settings.config_dir;
    let remembered = if force_new || explicit_id.is_some() {
        None
    } else {
        remember::load(config_dir, path)
            .filter(|m| m.server == server)
            .map(|m| m.id)
    };
    let explicit = explicit_id.map(docref::parse).transpose()?;
    let target = explicit.clone().or(remembered.clone());
    let resp = match target {
        Some(id) => match client
            .push(Some(&id), p.body.clone(), p.content_type, &q)
            .await
        {
            Ok(r) => r,
            Err(e) if exit::is_not_found(&e) && explicit.is_none() => {
                output::warn(format!(
                    "the remembered document {id} is gone; pushing a new document"
                ));
                client.push(None, p.body, p.content_type, &q).await?
            }
            Err(e) => return Err(e),
        },
        None => client.push(None, p.body, p.content_type, &q).await?,
    };
    let memo = Memo {
        id: resp.id.clone(),
        server,
    };
    if let Err(e) = remember::save(config_dir, path, &memo) {
        output::warn(format!("could not remember the document id: {e:#}"));
    }
    Ok(resp)
}

pub fn print_push(ctx: &Ctx, r: &PushResponse) {
    for b in &r.broken_links {
        output::warn(format!("broken link in {}: {}", b.from, b.href));
    }
    for w in &r.warnings {
        output::warn(w);
    }
    if ctx.json {
        output::json(r);
        return;
    }
    let files = if r.files == 1 {
        "1 file".to_string()
    } else {
        format!("{} files", r.files)
    };
    println!(
        "Pushed \"{}\" · r{} · {} · {}",
        r.title, r.rev, r.kind, files
    );
    println!("  url   {}", r.url);
    println!("  ws    {}", r.ws_url);
    println!("  wait  semora wait {}", r.id);
}

pub async fn run(ctx: &Ctx, args: PushArgs) -> anyhow::Result<i32> {
    let client = ctx.client()?;
    let query = PushQuery {
        title: args.title.clone(),
        tags: tags(&args.tags, &ctx.settings.default_tags),
        message: args.message.clone(),
        entry: args.entry.clone(),
        meta: parse_meta(&args.meta)?,
        filename: None,
    };
    let resp = push_path(
        ctx,
        &client,
        &args.path,
        args.id.as_deref(),
        args.new,
        &query,
    )
    .await?;
    if args.wait {
        if !ctx.json {
            print_push(ctx, &resp);
        } else {
            // With --wait --json, stdout carries only the events.
            for b in &resp.broken_links {
                output::warn(format!("broken link in {}: {}", b.from, b.href));
            }
        }
        return crate::commands::wait::wait_for(ctx, &client, &resp.id, &args.wait_opts).await;
    }
    print_push(ctx, &resp);
    Ok(exit::OK)
}

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

    #[test]
    fn meta_values_keep_scalar_types() {
        let m = parse_meta(&[
            "a=1".into(),
            "b=true".into(),
            "c=hello world".into(),
            "d=x=y".into(),
        ])
        .unwrap()
        .unwrap();
        let v: Value = serde_json::from_str(&m).unwrap();
        assert_eq!(
            v,
            serde_json::json!({"a": 1, "b": true, "c": "hello world", "d": "x=y"})
        );
        assert!(parse_meta(&["novalue".into()]).is_err());
        assert_eq!(parse_meta(&[]).unwrap(), None);
    }

    #[test]
    fn tags_merge_flags_and_defaults() {
        assert_eq!(
            tags(&["a,b".into(), "c".into()], &["proj".into()]).as_deref(),
            Some("a,b,c,proj")
        );
        assert_eq!(tags(&[], &[]), None);
    }
}