Skip to main content

semora_cli/commands/
push.rs

1//! `semora push`: publish a file or folder.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5
6use anyhow::{Context, bail};
7use semora_proto::PushResponse;
8use serde_json::{Map, Value};
9
10use crate::args::PushArgs;
11use crate::bundle;
12use crate::checkout::{self, Record};
13use crate::client::{Client, PushQuery, encode_path};
14use crate::project_link::{self, Link};
15use crate::remember::{self, Memo};
16use crate::{Ctx, docref, exit, output};
17
18/// What to send for `path`: body, content type, single-file name, warnings.
19pub struct Payload {
20    pub body: Vec<u8>,
21    pub content_type: &'static str,
22    pub filename: Option<String>,
23    /// The sha256 of each file sent, by its path in the pushed folder (`""` for a single file).
24    pub hashes: BTreeMap<String, String>,
25}
26
27pub fn payload(path: &Path) -> anyhow::Result<Payload> {
28    let meta =
29        std::fs::metadata(path).with_context(|| format!("cannot read {}", path.display()))?;
30    if meta.is_dir() {
31        let packed = bundle::pack_dir(path)?;
32        for (p, why) in &packed.skipped {
33            output::warn(format!("skipped {p} ({why})"));
34        }
35        if packed.files.is_empty() {
36            bail!("{} has no files Semora can serve", path.display());
37        }
38        if !packed.files.iter().any(|f| bundle::is_page(f)) {
39            bail!("{} has no Markdown or HTML page", path.display());
40        }
41        return Ok(Payload {
42            body: packed.tar,
43            content_type: "application/x-tar",
44            filename: None,
45            hashes: packed.hashes,
46        });
47    }
48    let name = path
49        .file_name()
50        .map(|n| n.to_string_lossy().into_owned())
51        .unwrap_or_default();
52    let lower = name.to_ascii_lowercase();
53    let content_type = if lower.ends_with(".md") || lower.ends_with(".markdown") {
54        "text/markdown; charset=utf-8"
55    } else if lower.ends_with(".html") || lower.ends_with(".htm") {
56        "text/html; charset=utf-8"
57    } else {
58        bail!("{name}: push a .md or .html file, or a folder");
59    };
60    let body = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
61    if content_type.starts_with("text/markdown") {
62        let links = bundle::local_page_links(&String::from_utf8_lossy(&body));
63        if !links.is_empty() {
64            output::warn(format!(
65                "{name} links to local pages ({}); push the folder to publish them together as a bundle",
66                links.join(", ")
67            ));
68        }
69    }
70    Ok(Payload {
71        hashes: [(String::new(), checkout::sha256(&body))].into(),
72        body,
73        content_type,
74        filename: Some(name),
75    })
76}
77
78/// `key=value` pairs; values that parse as JSON scalars keep their type.
79pub fn parse_meta(pairs: &[String]) -> anyhow::Result<Option<String>> {
80    if pairs.is_empty() {
81        return Ok(None);
82    }
83    let mut m = Map::new();
84    for p in pairs {
85        let (k, v) = p
86            .split_once('=')
87            .with_context(|| format!("--meta `{p}` must be key=value"))?;
88        let value = match serde_json::from_str::<Value>(v) {
89            Ok(j @ (Value::Number(_) | Value::Bool(_) | Value::Null)) => j,
90            _ => Value::String(v.to_string()),
91        };
92        m.insert(k.trim().to_string(), value);
93    }
94    Ok(Some(Value::Object(m).to_string()))
95}
96
97pub fn tags(args_tags: &[String], defaults: &[String]) -> Option<String> {
98    let all: Vec<String> = args_tags
99        .iter()
100        .flat_map(|t| t.split(','))
101        .chain(defaults.iter().map(String::as_str))
102        .map(str::trim)
103        .filter(|t| !t.is_empty())
104        .map(str::to_string)
105        .collect();
106    (!all.is_empty()).then(|| all.join(","))
107}
108
109/// One line when `--id` or `--new` pushes outside the project that holds `path`.
110pub fn note_outside_project(path: &Path, id: Option<&str>, new: bool) {
111    if id.is_none() && !new {
112        return;
113    }
114    let Ok(Some(link)) = project_link::find(path) else {
115        return;
116    };
117    if id.is_some() {
118        eprintln!(
119            "note: --id pushes to that document directly, not into project {}.",
120            link.slug
121        );
122    } else {
123        eprintln!(
124            "note: --new makes a document outside project {}.",
125            link.slug
126        );
127    }
128}
129
130/// Adds a command's own way out of a `.semora` for another server: `flags` is `--id or --new`
131/// for push, `--id` for watch. Other errors pass through.
132pub fn hint_outside_project(err: anyhow::Error, flags: &str) -> anyhow::Error {
133    if err.downcast_ref::<project_link::OtherServer>().is_some() {
134        anyhow::anyhow!("{err} Or use {flags} to push outside the project.")
135    } else {
136        err
137    }
138}
139
140/// An old server ignores the project parameters and makes a plain document.
141pub fn check_filed(resp: &PushResponse, slug: &str) -> anyhow::Result<()> {
142    if resp.project.is_none() {
143        bail!(
144            "the server did not file this under project `{slug}`; it may be older than this CLI. Update the server."
145        );
146    }
147    Ok(())
148}
149
150/// A push the server refused because someone pushed after the revision this checkout has.
151fn stale_push(
152    client: &Client,
153    link: &Link,
154    key: &str,
155    path: &Path,
156    bundle: bool,
157    err: &anyhow::Error,
158) -> anyhow::Error {
159    let shown = path.display();
160    let url = client.url(&format!("/p/{}/{}", link.slug, encode_path(key)));
161    let get = if bundle {
162        format!("`semora show {url}` lists its files, `semora get {url} FILE` prints one")
163    } else {
164        format!("`semora get {url}` prints it")
165    };
166    exit::stale(format!(
167        "{}. It changed on the server after this folder last pulled or pushed it. Merge the server's copy into {shown} ({get}) and push again with --force, or run `semora pull --force {shown}` to take the server's copy and drop your changes.",
168        exit::message_of(err)
169    ))
170}
171
172/// A push by key: the server updates the document at `key` in the project, or makes one. When
173/// this checkout has a record of the document, the push names that revision, and the server
174/// refuses it if someone pushed since; `force` leaves the revision out.
175#[allow(clippy::too_many_arguments)]
176async fn push_keyed(
177    ctx: &Ctx,
178    client: &Client,
179    path: &Path,
180    link: &Link,
181    key: &str,
182    p: Payload,
183    mut q: PushQuery,
184    force: bool,
185) -> anyhow::Result<PushResponse> {
186    let server = client.origin();
187    q.project = Some(link.slug.clone());
188    q.path = Some(key.to_string());
189    q.origin = Some(project_link::origin_for(path));
190    // An older document remembered for this path is carried into the project.
191    q.adopt = remember::load(&ctx.settings.config_dir, path)
192        .filter(|m| m.server == server)
193        .map(|m| m.id);
194    let known = if force {
195        None
196    } else {
197        checkout::load(link)?.remove(key)
198    };
199    let if_match = known.map(|r| {
200        q.base = Some(r.id);
201        r.rev
202    });
203    let files: BTreeMap<String, String> = p
204        .hashes
205        .iter()
206        .map(|(rel, hash)| {
207            let local = if rel.is_empty() {
208                key.to_string()
209            } else {
210                format!("{key}/{rel}")
211            };
212            (local, hash.clone())
213        })
214        .collect();
215    let bundle = p.filename.is_none();
216    let resp = match client
217        .push(None, p.body, p.content_type, &q, if_match)
218        .await
219    {
220        Ok(r) => r,
221        Err(e) if exit::is_stale(&e) => {
222            return Err(stale_push(client, link, key, path, bundle, &e));
223        }
224        Err(e) if exit::is_not_found(&e) => {
225            return Err(exit::not_found(format!(
226                "project `{}` was not found on {server}. Run `semora project init` to create it again, or you may no longer have access to this project.",
227                link.slug
228            )));
229        }
230        Err(e) if exit::is_conflict(&e) => bail!(
231            "the server could not file this path in project `{}`: another push got there first, or you may no longer have access to this project. Try again.",
232            link.slug
233        ),
234        Err(e) => return Err(e),
235    };
236    check_filed(&resp, &link.slug)?;
237    let record = Record {
238        id: resp.id.clone(),
239        rev: resp.rev,
240        kind: resp.kind,
241        files,
242    };
243    if let Err(e) = checkout::update(link, |r| r.insert(key.to_string(), record)) {
244        output::warn(format!(
245            "could not record the pushed revision, so the next push of {key} can't check for newer ones: {e:#}"
246        ));
247    }
248    Ok(resp)
249}
250
251/// Pushes `path`. Inside a project (a `.semora` above it) it goes by key, unless `--id` or
252/// `--new` says otherwise; outside one it replaces the explicit or remembered document.
253/// `force` pushes by key even when the server has a newer revision than this checkout.
254pub async fn push_path(
255    ctx: &Ctx,
256    client: &Client,
257    path: &Path,
258    explicit_id: Option<&str>,
259    force_new: bool,
260    force: bool,
261    query: &PushQuery,
262) -> anyhow::Result<PushResponse> {
263    // Decided before anything is read or packed, so a refusal costs nothing.
264    let keyed = if explicit_id.is_none() && !force_new {
265        match project_link::find(path)? {
266            Some(link) => {
267                link.check_server(client)?;
268                let key = link.relative_path(path)?;
269                Some((link, key))
270            }
271            None => None,
272        }
273    } else {
274        None
275    };
276    let p = payload(path)?;
277    let mut q = query.clone();
278    q.filename = p.filename.clone();
279    if let Some((link, key)) = &keyed {
280        return push_keyed(ctx, client, path, link, key, p, q, force).await;
281    }
282    let server = client.origin();
283    let config_dir = &ctx.settings.config_dir;
284    let remembered = if force_new || explicit_id.is_some() {
285        None
286    } else {
287        remember::load(config_dir, path)
288            .filter(|m| m.server == server)
289            .map(|m| m.id)
290    };
291    let explicit = match explicit_id {
292        Some(s) => Some(docref::resolve(client, s).await?),
293        None => None,
294    };
295    let target = explicit.clone().or(remembered.clone());
296    let resp = match target {
297        Some(id) => match client
298            .push(Some(&id), p.body.clone(), p.content_type, &q, None)
299            .await
300        {
301            Ok(r) => r,
302            Err(e) if exit::is_not_found(&e) && explicit.is_none() => {
303                output::warn(format!(
304                    "the remembered document {id} is gone; pushing a new document"
305                ));
306                client.push(None, p.body, p.content_type, &q, None).await?
307            }
308            Err(e) => return Err(e),
309        },
310        None => client.push(None, p.body, p.content_type, &q, None).await?,
311    };
312    let memo = Memo {
313        id: resp.id.clone(),
314        server,
315    };
316    if let Err(e) = remember::save(config_dir, path, &memo) {
317        output::warn(format!("could not remember the document id: {e:#}"));
318    }
319    Ok(resp)
320}
321
322/// One line on stderr when a push with a `--message` made no revision. A message belongs to a
323/// revision, so an unchanged push has nowhere to keep it.
324fn note_unrecorded_message(r: &PushResponse, message: Option<&str>) {
325    if r.unchanged && message.is_some_and(|m| !m.trim().is_empty()) {
326        eprintln!(
327            "note: the content was unchanged, so no revision was made and the message was not recorded."
328        );
329    }
330}
331
332pub fn print_push(ctx: &Ctx, r: &PushResponse) {
333    for b in &r.broken_links {
334        output::warn(format!("broken link in {}: {}", b.from, b.href));
335    }
336    for w in &r.warnings {
337        output::warn(w);
338    }
339    if ctx.json {
340        output::json(r);
341        return;
342    }
343    let files = if r.files == 1 {
344        "1 file".to_string()
345    } else {
346        format!("{} files", r.files)
347    };
348    if r.unchanged {
349        println!(
350            "Nothing changed: \"{}\" stays at r{} · {} · {}",
351            r.title, r.rev, r.kind, files
352        );
353    } else {
354        println!(
355            "Pushed \"{}\" · r{} · {} · {}",
356            r.title, r.rev, r.kind, files
357        );
358    }
359    if let Some(u) = &r.path_url {
360        println!("  path  {u}");
361    }
362    println!("  url   {}", r.url);
363    println!("  ws    {}", r.ws_url);
364    println!("  wait  semora wait {}", r.id);
365}
366
367pub async fn run(ctx: &Ctx, args: PushArgs) -> anyhow::Result<i32> {
368    let client = ctx.client()?;
369    note_outside_project(&args.path, args.id.as_deref(), args.new);
370    let query = PushQuery {
371        title: args.title.clone(),
372        tags: tags(&args.tags, &ctx.settings.default_tags),
373        message: args.message.clone(),
374        entry: args.entry.clone(),
375        meta: parse_meta(&args.meta)?,
376        ..Default::default()
377    };
378    let resp = push_path(
379        ctx,
380        &client,
381        &args.path,
382        args.id.as_deref(),
383        args.new,
384        args.force,
385        &query,
386    )
387    .await
388    .map_err(|e| hint_outside_project(e, "--id or --new"))?;
389    note_unrecorded_message(&resp, args.message.as_deref());
390    if args.wait {
391        if !ctx.json {
392            print_push(ctx, &resp);
393        } else {
394            // With --wait --json, stdout carries only the events.
395            for b in &resp.broken_links {
396                output::warn(format!("broken link in {}: {}", b.from, b.href));
397            }
398        }
399        return crate::commands::wait::wait_for(ctx, &client, &resp.id, &args.wait_opts).await;
400    }
401    print_push(ctx, &resp);
402    Ok(exit::OK)
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn meta_values_keep_scalar_types() {
411        let m = parse_meta(&[
412            "a=1".into(),
413            "b=true".into(),
414            "c=hello world".into(),
415            "d=x=y".into(),
416        ])
417        .unwrap()
418        .unwrap();
419        let v: Value = serde_json::from_str(&m).unwrap();
420        assert_eq!(
421            v,
422            serde_json::json!({"a": 1, "b": true, "c": "hello world", "d": "x=y"})
423        );
424        assert!(parse_meta(&["novalue".into()]).is_err());
425        assert_eq!(parse_meta(&[]).unwrap(), None);
426    }
427
428    #[test]
429    fn an_old_server_without_projects_is_reported() {
430        let mut r = PushResponse {
431            id: "k3F9xQ2mLp".into(),
432            rev: 1,
433            kind: semora_proto::DocKind::Md,
434            url: "u".into(),
435            ws_url: "w".into(),
436            files: 1,
437            title: "T".into(),
438            unchanged: false,
439            broken_links: vec![],
440            warnings: vec![],
441            project: None,
442            path: None,
443            path_url: None,
444        };
445        let err = check_filed(&r, "semora").unwrap_err().to_string();
446        assert!(
447            err.contains("older than this CLI") && err.contains("semora"),
448            "{err}"
449        );
450        r.project = Some("semora".into());
451        assert!(check_filed(&r, "semora").is_ok());
452    }
453
454    #[test]
455    fn tags_merge_flags_and_defaults() {
456        assert_eq!(
457            tags(&["a,b".into(), "c".into()], &["proj".into()]).as_deref(),
458            Some("a,b,c,proj")
459        );
460        assert_eq!(tags(&[], &[]), None);
461    }
462}