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