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(link) => {
279                link.check_server(client)?;
280                let key = link.relative_path(path)?;
281                Some((link, key))
282            }
283            None => None,
284        }
285    } else {
286        None
287    };
288    let p = payload(path, query.entry.as_deref())?;
289    let mut q = query.clone();
290    q.filename = p.filename.clone();
291    if let Some((link, key)) = &keyed {
292        return push_keyed(ctx, client, path, link, key, p, q, force).await;
293    }
294    let server = client.origin();
295    let config_dir = &ctx.settings.config_dir;
296    let remembered = if force_new || explicit_id.is_some() {
297        None
298    } else {
299        remember::load(config_dir, path)
300            .filter(|m| m.server == server)
301            .map(|m| m.id)
302    };
303    let explicit = match explicit_id {
304        Some(s) => Some(docref::resolve(client, s).await?),
305        None => None,
306    };
307    let target = explicit.clone().or(remembered.clone());
308    let resp = match target {
309        Some(id) => match client
310            .push(Some(&id), p.body.clone(), p.content_type, &q, None)
311            .await
312        {
313            Ok(r) => r,
314            Err(e) if exit::is_not_found(&e) && explicit.is_none() => {
315                output::warn(format!(
316                    "the remembered document {id} is gone; pushing a new document"
317                ));
318                client.push(None, p.body, p.content_type, &q, None).await?
319            }
320            Err(e) => return Err(e),
321        },
322        None => client.push(None, p.body, p.content_type, &q, None).await?,
323    };
324    let memo = Memo {
325        id: resp.id.clone(),
326        server,
327    };
328    if let Err(e) = remember::save(config_dir, path, &memo) {
329        output::warn(format!("could not remember the document id: {e:#}"));
330    }
331    Ok(resp)
332}
333
334/// One line on stderr when a push with a `--message` made no revision. A message belongs to a
335/// revision, so an unchanged push has nowhere to keep it.
336fn note_unrecorded_message(r: &PushResponse, message: Option<&str>) {
337    if r.unchanged && message.is_some_and(|m| !m.trim().is_empty()) {
338        eprintln!(
339            "note: the content was unchanged, so no revision was made and the message was not recorded."
340        );
341    }
342}
343
344pub fn print_push(ctx: &Ctx, r: &PushResponse) {
345    for b in &r.broken_links {
346        output::warn(format!("broken link in {}: {}", b.from, b.href));
347    }
348    for w in &r.warnings {
349        output::warn(w);
350    }
351    if ctx.json {
352        output::json(r);
353        return;
354    }
355    let link = output::LINK;
356    let files = if r.files == 1 {
357        "1 file".to_string()
358    } else {
359        format!("{} files", r.files)
360    };
361    if r.unchanged {
362        println!(
363            "Nothing changed: \"{}\" stays at r{} · {} · {}",
364            r.title, r.rev, r.kind, files
365        );
366    } else {
367        let done = output::out().done;
368        anstream::println!(
369            "{done}Pushed{done:#} \"{}\" · r{} · {} · {}",
370            r.title,
371            r.rev,
372            r.kind,
373            files
374        );
375    }
376    if let Some(u) = &r.path_url {
377        anstream::println!("  path  {link}{u}{link:#}");
378    }
379    anstream::println!("  url   {link}{}{link:#}", r.url);
380    println!("  ws    {}", r.ws_url);
381    println!("  wait  semora wait {}", r.id);
382}
383
384pub async fn run(ctx: &Ctx, args: PushArgs) -> anyhow::Result<i32> {
385    let client = ctx.client()?;
386    note_outside_project(&args.path, args.id.as_deref(), args.new);
387    let query = PushQuery {
388        title: args.title.clone(),
389        tags: tags(&args.tags, &ctx.settings.default_tags),
390        message: args.message.clone(),
391        entry: args.entry.clone(),
392        meta: parse_meta(&args.meta)?,
393        ..Default::default()
394    };
395    let resp = push_path(
396        ctx,
397        &client,
398        &args.path,
399        args.id.as_deref(),
400        args.new,
401        args.force,
402        &query,
403    )
404    .await
405    .map_err(|e| hint_outside_project(e, "--id or --new"))?;
406    note_unrecorded_message(&resp, args.message.as_deref());
407    if args.wait {
408        if !ctx.json {
409            print_push(ctx, &resp);
410        } else {
411            // With --wait --json, stdout carries only the events.
412            for b in &resp.broken_links {
413                output::warn(format!("broken link in {}: {}", b.from, b.href));
414            }
415        }
416        return crate::commands::wait::wait_for(ctx, &client, &resp.id, &args.wait_opts).await;
417    }
418    print_push(ctx, &resp);
419    Ok(exit::OK)
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn meta_values_keep_scalar_types() {
428        let m = parse_meta(&[
429            "a=1".into(),
430            "b=true".into(),
431            "c=hello world".into(),
432            "d=x=y".into(),
433        ])
434        .unwrap()
435        .unwrap();
436        let v: Value = serde_json::from_str(&m).unwrap();
437        assert_eq!(
438            v,
439            serde_json::json!({"a": 1, "b": true, "c": "hello world", "d": "x=y"})
440        );
441        assert!(parse_meta(&["novalue".into()]).is_err());
442        assert_eq!(parse_meta(&[]).unwrap(), None);
443    }
444
445    #[test]
446    fn an_old_server_without_projects_is_reported() {
447        let mut r = PushResponse {
448            id: "k3F9xQ2mLp".into(),
449            rev: 1,
450            kind: semora_proto::DocKind::Md,
451            url: "u".into(),
452            ws_url: "w".into(),
453            files: 1,
454            title: "T".into(),
455            unchanged: false,
456            broken_links: vec![],
457            warnings: vec![],
458            project: None,
459            path: None,
460            path_url: None,
461        };
462        let err = check_filed(&r, "semora").unwrap_err().to_string();
463        assert!(
464            err.contains("older than this CLI") && err.contains("semora"),
465            "{err}"
466        );
467        r.project = Some("semora".into());
468        assert!(check_filed(&r, "semora").is_ok());
469    }
470
471    #[test]
472    fn tags_merge_flags_and_defaults() {
473        assert_eq!(
474            tags(&["a,b".into(), "c".into()], &["proj".into()]).as_deref(),
475            Some("a,b,c,proj")
476        );
477        assert_eq!(tags(&[], &[]), None);
478    }
479}