Skip to main content

semora_cli/commands/
pull.rs

1//! `semora pull` and `semora status`: a project's documents on the server, against this folder.
2//!
3//! Both compare three things for each document path: what the server has now, what this
4//! checkout last pulled or pushed (its record), and what is on disk. `status` prints that;
5//! `pull` writes what is newer on the server and never overwrites a file changed here unless
6//! told to.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::io::ErrorKind;
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context, bail};
13use semora_proto::{DocDetail, DocKind, DocSummary};
14use serde::Serialize;
15
16use crate::args::PullArgs;
17use crate::checkout::{self, Local, Record};
18use crate::client::Client;
19use crate::project_link::{self, Link, shown};
20use crate::{Ctx, exit, output};
21
22/// Where a document stands, here against the server.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24#[serde(rename_all = "snake_case")]
25pub enum State {
26    /// The same here and on the server.
27    Clean,
28    /// Changed here since the last pull or push; nothing newer on the server.
29    Modified,
30    /// Newer on the server; unchanged here.
31    Behind,
32    /// On the server, not here.
33    Missing,
34    /// Changed here and on the server.
35    Diverged,
36    /// Here and on the server and different, and this folder never pulled or pushed it.
37    Untracked,
38    /// Deleted on the server; the copy here is unchanged.
39    Deleted,
40    /// Deleted on the server, and changed here.
41    DeletedModified,
42    /// It can't be a file or folder here.
43    Blocked,
44    /// Deleted on the server and gone here: only the record is left.
45    #[serde(skip)]
46    Gone,
47}
48
49impl State {
50    fn label(self) -> &'static str {
51        match self {
52            State::Clean => "same",
53            State::Modified => "changed here",
54            State::Behind => "newer on the server",
55            State::Missing => "not here",
56            State::Diverged => "changed on both",
57            State::Untracked => "different, never pulled",
58            State::Deleted | State::DeletedModified | State::Gone => "deleted on the server",
59            State::Blocked => "can't be here",
60        }
61    }
62}
63
64/// One document path, as `status` reports it.
65#[derive(Debug, Serialize)]
66pub struct Entry {
67    pub path: String,
68    pub state: State,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub id: Option<String>,
71    /// The revision this folder last pulled or pushed.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub here: Option<i64>,
74    /// The revision on the server now.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub server: Option<i64>,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub reason: Option<String>,
79}
80
81/// A document path with everything known about it.
82struct Item {
83    key: String,
84    on_server: Option<DocSummary>,
85    record: Option<Record>,
86    state: State,
87    reason: Option<String>,
88    /// The server's detail, when classifying needed it.
89    detail: Option<DocDetail>,
90}
91
92impl Item {
93    fn entry(&self) -> Entry {
94        Entry {
95            path: self.key.clone(),
96            state: self.state,
97            id: self
98                .on_server
99                .as_ref()
100                .map(|s| s.id.clone())
101                .or_else(|| self.record.as_ref().map(|r| r.id.clone())),
102            here: self.record.as_ref().map(|r| r.rev),
103            server: self
104                .detail
105                .as_ref()
106                .map(|d| d.summary.rev)
107                .or_else(|| self.on_server.as_ref().map(|s| s.rev)),
108            reason: self.reason.clone(),
109        }
110    }
111}
112
113/// The project folder for `path` (default: the current folder), and the document path it
114/// narrows to (`None`: the whole project). The path need not exist yet.
115fn scope(path: Option<&Path>) -> anyhow::Result<(project_link::FileLink, Option<String>)> {
116    let start = match path {
117        Some(p) => std::path::absolute(p).with_context(|| format!("cannot read {}", shown(p)))?,
118        None => std::env::current_dir().context("reading the current folder")?,
119    };
120    // The nearest folder that exists: a path that was never pulled has none of its own.
121    let mut existing = start.as_path();
122    let mut tail = Vec::new();
123    while !existing.exists() {
124        let name = existing
125            .file_name()
126            .with_context(|| format!("{} does not exist", shown(&start)))?;
127        tail.push(name.to_owned());
128        existing = existing
129            .parent()
130            .with_context(|| format!("{} does not exist", shown(&start)))?;
131    }
132    let mut full = std::fs::canonicalize(existing)
133        .with_context(|| format!("cannot read {}", shown(existing)))?;
134    let link = project_link::find(&full)?.with_context(|| {
135        format!(
136            "{} is not in a project: no {} here or in a parent folder. Run `semora project init` to make one.",
137            shown(&full),
138            project_link::FILE
139        )
140    })?;
141    for name in tail.into_iter().rev() {
142        full.push(name);
143    }
144    let rel = full.strip_prefix(&link.root).with_context(|| {
145        format!(
146            "{} is not inside the project at {}",
147            shown(&full),
148            shown(&link.root)
149        )
150    })?;
151    let mut parts = Vec::new();
152    for c in rel.components() {
153        let s = c
154            .as_os_str()
155            .to_str()
156            .with_context(|| format!("{} isn't valid Unicode", shown(&full)))?;
157        parts.push(s.to_string());
158    }
159    Ok((link, (!parts.is_empty()).then(|| parts.join("/"))))
160}
161
162/// A document is in scope when it is at the path, under it, or holds it (a bundle).
163fn in_scope(key: &str, prefix: Option<&str>) -> bool {
164    match prefix {
165        None => true,
166        Some(p) => {
167            key == p
168                || key.strip_prefix(p).is_some_and(|r| r.starts_with('/'))
169                || p.strip_prefix(key).is_some_and(|r| r.starts_with('/'))
170        }
171    }
172}
173
174/// The server's files of a document, by path from the project folder, with their hashes.
175fn server_files(key: &str, d: &DocDetail) -> Result<BTreeMap<String, String>, String> {
176    let mut out = BTreeMap::new();
177    if d.summary.kind == DocKind::Bundle {
178        for f in &d.files {
179            let local = format!("{key}/{}", f.path);
180            if checkout::local_rel(&local).is_none() {
181                return Err(format!(
182                    "the server lists a file `{}` that can't be written here",
183                    f.path.escape_default()
184                ));
185            }
186            out.insert(local, f.hash.clone());
187        }
188    } else {
189        let entry = d
190            .files
191            .iter()
192            .find(|f| f.path == d.entry_path)
193            .or(d.files.first())
194            .ok_or_else(|| "the server lists no file for it".to_string())?;
195        out.insert(key.to_string(), entry.hash.clone());
196    }
197    Ok(out)
198}
199
200/// What a folder or file of the wrong type says.
201fn wrong_type(kind: DocKind) -> String {
202    if kind == DocKind::Bundle {
203        "it is a folder on the server, and a file is here".into()
204    } else {
205        "it is a file on the server, and a folder is here (or its path runs through a file)".into()
206    }
207}
208
209/// Every document path in scope, classified.
210async fn survey(client: &Client, link: &Link, prefix: Option<&str>) -> anyhow::Result<Vec<Item>> {
211    let docs = client.project_docs(&link.slug).await?;
212    let records = checkout::load(link)?;
213    let on_server: BTreeMap<String, DocSummary> = docs
214        .into_iter()
215        .filter_map(|d| Some((d.path.clone()?, d)))
216        .collect();
217    let keys: BTreeSet<&String> = on_server
218        .keys()
219        .chain(records.keys())
220        .filter(|k| in_scope(k, prefix))
221        .collect();
222    let mut items = Vec::new();
223    for key in keys {
224        let s = on_server.get(key).cloned();
225        let r = records.get(key).cloned();
226        let mut item = Item {
227            key: key.clone(),
228            on_server: s.clone(),
229            record: r.clone(),
230            state: State::Clean,
231            reason: None,
232            detail: None,
233        };
234        let kind = s.as_ref().map(|s| s.kind).or(r.as_ref().map(|r| r.kind));
235        let local = checkout::local(&link.root, key, kind.expect("on the server or recorded"))?;
236        let Some(s) = s else {
237            let r = r.expect("recorded");
238            item.state = match local {
239                Local::Missing => State::Gone,
240                Local::Files(f) if f == r.files => State::Deleted,
241                _ => State::DeletedModified,
242            };
243            items.push(item);
244            continue;
245        };
246        // A document whose path holds others can't be a file or a folder of its own here.
247        let prefix_of = format!("{key}/");
248        if let Some(inner) = on_server.keys().find(|k| k.starts_with(&prefix_of)) {
249            item.state = State::Blocked;
250            item.reason = Some(format!(
251                "`{inner}` is a document of its own inside its path, so it can't be a {} here",
252                if s.kind == DocKind::Bundle {
253                    "folder"
254                } else {
255                    "file"
256                }
257            ));
258            items.push(item);
259            continue;
260        }
261        if checkout::local_rel(key).is_none() {
262            item.state = State::Blocked;
263            item.reason = Some("its path can't be a file name here".into());
264            items.push(item);
265            continue;
266        }
267        item.state = match (local, &r) {
268            (Local::WrongType, _) => {
269                item.reason = Some(wrong_type(s.kind));
270                State::Blocked
271            }
272            (Local::Missing, _) => State::Missing,
273            (Local::Files(f), Some(r)) => {
274                let same = r.id == s.id && r.rev == s.rev;
275                match (same, f == r.files) {
276                    (true, true) => State::Clean,
277                    (true, false) => State::Modified,
278                    (false, true) => State::Behind,
279                    (false, false) => State::Diverged,
280                }
281            }
282            (Local::Files(f), None) => {
283                // Never pulled or pushed here: compare with the server's files.
284                let d = client.doc(&s.id).await?;
285                let state = match server_files(key, &d) {
286                    Ok(theirs) if theirs == f => State::Clean,
287                    Ok(_) => State::Untracked,
288                    Err(why) => {
289                        item.reason = Some(why);
290                        State::Blocked
291                    }
292                };
293                item.detail = Some(d);
294                state
295            }
296        };
297        items.push(item);
298    }
299    Ok(items)
300}
301
302/// The client, the project (under the id the server has for it now) and the path prefix.
303async fn project_and_scope(
304    ctx: &Ctx,
305    path: Option<&Path>,
306) -> anyhow::Result<(Client, Link, Option<String>)> {
307    let client = ctx.client()?;
308    let (file, prefix) = scope(path)?;
309    let link = project_link::resolve(&client, &ctx.settings.config_dir, file).await?;
310    let link = project_link::follow(&client, link).await?;
311    Ok((client, link, prefix))
312}
313
314pub async fn status(ctx: &Ctx, path: Option<PathBuf>) -> anyhow::Result<i32> {
315    let (client, link, prefix) = project_and_scope(ctx, path.as_deref()).await?;
316    let items = survey(&client, &link, prefix.as_deref()).await?;
317    let entries: Vec<Entry> = items
318        .iter()
319        .filter(|i| i.state != State::Gone)
320        .map(Item::entry)
321        .collect();
322    if ctx.json {
323        output::json(&serde_json::json!({
324            "project": link.slug,
325            "folder": shown(&link.root),
326            "documents": entries,
327        }));
328        return Ok(exit::OK);
329    }
330    let open: Vec<&Entry> = entries.iter().filter(|e| e.state != State::Clean).collect();
331    println!("Project {} in {}", link.slug, shown(&link.root));
332    if open.is_empty() {
333        println!(
334            "Everything here matches the server ({}).",
335            count(entries.len(), "document")
336        );
337        return Ok(exit::OK);
338    }
339    let width = open
340        .iter()
341        .map(|e| e.state.label().len())
342        .max()
343        .unwrap_or(0);
344    for e in &open {
345        let revs = match (e.state, e.here, e.server) {
346            (State::Modified, Some(h), _) => format!("  (from r{h})"),
347            (State::Behind, Some(h), Some(s)) => format!("  (r{h} here, r{s} on the server)"),
348            (State::Diverged, Some(h), Some(s)) => format!("  (from r{h}; the server has r{s})"),
349            (State::Missing | State::Untracked, _, Some(s)) => format!("  (r{s})"),
350            (State::DeletedModified, _, _) => "  (changed here)".into(),
351            _ => String::new(),
352        };
353        let why = e
354            .reason
355            .as_deref()
356            .map(|r| format!(": {r}"))
357            .unwrap_or_default();
358        println!("  {:<width$}  {}{revs}{why}", e.state.label(), e.path);
359    }
360    let pull = open.iter().any(|e| {
361        matches!(
362            e.state,
363            State::Behind | State::Missing | State::Deleted | State::Diverged | State::Untracked
364        )
365    });
366    let push = open
367        .iter()
368        .any(|e| matches!(e.state, State::Modified | State::Diverged));
369    match (pull, push) {
370        (true, true) => println!(
371            "Pull to get what is newer on the server; merge what changed on both before you push it."
372        ),
373        (true, false) => println!("Run `semora pull` to get what is newer on the server."),
374        (false, true) => println!("Push what changed here."),
375        (false, false) => {}
376    }
377    Ok(exit::OK)
378}
379
380fn count(n: usize, what: &str) -> String {
381    if n == 1 {
382        format!("1 {what}")
383    } else {
384        format!("{n} {what}s")
385    }
386}
387
388/// What `pull` did with one document.
389#[derive(Debug, Serialize)]
390struct Done {
391    path: String,
392    #[serde(skip_serializing_if = "Option::is_none")]
393    id: Option<String>,
394    #[serde(skip_serializing_if = "Option::is_none")]
395    rev: Option<i64>,
396    /// Why it was skipped, or that it changed here and isn't pushed yet.
397    #[serde(skip_serializing_if = "Option::is_none")]
398    note: Option<String>,
399}
400
401#[derive(Debug, Default, Serialize)]
402struct Pulled {
403    written: Vec<Done>,
404    unchanged: Vec<Done>,
405    skipped: Vec<Done>,
406    deleted: Vec<Done>,
407}
408
409/// Writes `data` to `path` through a hidden file beside it, so a reader never sees half of it.
410fn write_file(path: &Path, data: &[u8]) -> anyhow::Result<()> {
411    let dir = path.parent().expect("a path in the project folder");
412    std::fs::create_dir_all(dir).with_context(|| format!("creating {}", shown(dir)))?;
413    let name = path
414        .file_name()
415        .expect("a file name")
416        .to_string_lossy()
417        .into_owned();
418    let tmp = dir.join(format!(".{name}.{:08x}.semora-tmp", rand::random::<u32>()));
419    std::fs::write(&tmp, data).with_context(|| format!("writing {}", shown(&tmp)))?;
420    if let Err(e) = std::fs::rename(&tmp, path) {
421        let _ = std::fs::remove_file(&tmp);
422        return Err(e).with_context(|| format!("writing {}", shown(path)));
423    }
424    Ok(())
425}
426
427/// The sha256 of the file at `path`, if there is one.
428fn hash_of(path: &Path) -> Option<String> {
429    std::fs::read(path).ok().map(|d| checkout::sha256(&d))
430}
431
432/// Removes `rel` from the project folder, then the folders above it inside `stop` that it left
433/// empty.
434fn remove_tracked(root: &Path, rel: &str, stop: &Path) -> anyhow::Result<()> {
435    let Some(p) = checkout::local_rel(rel) else {
436        return Ok(());
437    };
438    let full = root.join(p);
439    match std::fs::remove_file(&full) {
440        Ok(()) => {}
441        Err(e) if e.kind() == ErrorKind::NotFound => {}
442        Err(e) => return Err(e).with_context(|| format!("removing {}", shown(&full))),
443    }
444    let mut dir = full.parent();
445    while let Some(d) = dir {
446        if !d.starts_with(stop) || std::fs::remove_dir(d).is_err() {
447            break;
448        }
449        dir = d.parent();
450    }
451    Ok(())
452}
453
454/// Writes the server's current revision of a document into the folder. Files a newer revision
455/// dropped go too, when they are as this folder last had them. Returns the new record, or why it
456/// was skipped.
457async fn write_doc(
458    client: &Client,
459    root: &Path,
460    item: &Item,
461) -> anyhow::Result<Result<Record, String>> {
462    let s = item.on_server.as_ref().expect("on the server");
463    let d = match &item.detail {
464        Some(d) => d.clone(),
465        None => client.doc(&s.id).await?,
466    };
467    let theirs = match server_files(&item.key, &d) {
468        Ok(t) => t,
469        Err(why) => return Ok(Err(why)),
470    };
471    let rev = d.summary.rev;
472    for (local, hash) in &theirs {
473        let path = root.join(checkout::local_rel(local).expect("checked by server_files"));
474        if hash_of(&path).as_deref() == Some(hash.as_str()) {
475            continue;
476        }
477        let in_doc = local
478            .strip_prefix(&item.key)
479            .and_then(|r| r.strip_prefix('/'))
480            .filter(|_| d.summary.kind == DocKind::Bundle);
481        let data = client.raw(&d.summary.id, in_doc, Some(rev)).await?;
482        if checkout::sha256(&data) != *hash {
483            bail!(
484                "the server sent {local} with other content than it listed; nothing of it was written. Try again."
485            );
486        }
487        write_file(&path, &data)?;
488    }
489    if let Some(r) = &item.record {
490        let stop = root.join(checkout::local_rel(&item.key).expect("checked"));
491        for (old, hash) in &r.files {
492            if theirs.contains_key(old) {
493                continue;
494            }
495            let Some(rel) = checkout::local_rel(old) else {
496                continue;
497            };
498            if hash_of(&root.join(rel)).as_deref() == Some(hash.as_str()) {
499                remove_tracked(root, old, &stop)?;
500            }
501        }
502    }
503    Ok(Ok(Record {
504        id: d.summary.id.clone(),
505        rev,
506        kind: d.summary.kind,
507        files: theirs,
508    }))
509}
510
511pub async fn pull(ctx: &Ctx, args: PullArgs) -> anyhow::Result<i32> {
512    let (client, link, prefix) = project_and_scope(ctx, args.path.as_deref()).await?;
513    let items = survey(&client, &link, prefix.as_deref()).await?;
514    let root = link.root.clone();
515    let mut out = Pulled::default();
516    for item in &items {
517        let e = item.entry();
518        let done = |note: Option<String>| Done {
519            path: e.path.clone(),
520            id: e.id.clone(),
521            rev: e.server.or(e.here),
522            note,
523        };
524        let write = match item.state {
525            State::Behind | State::Missing => true,
526            State::Diverged | State::Untracked => args.force,
527            _ => false,
528        };
529        if write {
530            match write_doc(&client, &root, item).await? {
531                Ok(record) => {
532                    let rev = record.rev;
533                    checkout::update(&link, |r| r.insert(item.key.clone(), record))?;
534                    out.written.push(Done {
535                        rev: Some(rev),
536                        ..done(None)
537                    });
538                }
539                Err(why) => out.skipped.push(done(Some(why))),
540            }
541            continue;
542        }
543        match item.state {
544            State::Clean => {
545                // Already the same; a folder that never pulled it records it now.
546                if item.record.is_none()
547                    && let Some(d) = &item.detail
548                    && let Ok(files) = server_files(&item.key, d)
549                {
550                    let record = Record {
551                        id: d.summary.id.clone(),
552                        rev: d.summary.rev,
553                        kind: d.summary.kind,
554                        files,
555                    };
556                    checkout::update(&link, |r| r.insert(item.key.clone(), record))?;
557                }
558                out.unchanged.push(done(None));
559            }
560            State::Modified => out
561                .unchanged
562                .push(done(Some("changed here, not pushed yet".into()))),
563            State::Diverged => out.skipped.push(done(Some(format!(
564                "changed here and on the server (this folder has r{}, the server r{}). Merge the server's copy into it and push with --force, or pull --force to take the server's",
565                e.here.unwrap_or_default(),
566                e.server.unwrap_or_default()
567            )))),
568            State::Untracked => out.skipped.push(done(Some(
569                "already here and different from the server's, and this folder never pulled or pushed it. pull --force takes the server's; push --force keeps yours".into(),
570            ))),
571            State::Deleted => {
572                let r = item.record.as_ref().expect("recorded");
573                let stop = root.join(checkout::local_rel(&item.key).unwrap_or_default());
574                for f in r.files.keys() {
575                    remove_tracked(&root, f, &stop)?;
576                }
577                checkout::update(&link, |recs| recs.remove(&item.key))?;
578                out.deleted.push(done(None));
579            }
580            State::DeletedModified => out.skipped.push(done(Some(
581                "deleted on the server, and changed here. Push it with --force to put it back, or delete it".into(),
582            ))),
583            State::Gone => {
584                checkout::update(&link, |recs| recs.remove(&item.key))?;
585            }
586            State::Blocked => out.skipped.push(done(item.reason.clone())),
587            State::Behind | State::Missing => unreachable!("written above"),
588        }
589    }
590    if ctx.json {
591        output::json(&out);
592        return Ok(exit::OK);
593    }
594    print_pulled(&link, &out);
595    Ok(exit::OK)
596}
597
598fn print_pulled(link: &Link, out: &Pulled) {
599    let r = |d: &Done| d.rev.map(|r| format!(" (r{r})")).unwrap_or_default();
600    if out.written.is_empty() && out.deleted.is_empty() && out.skipped.is_empty() {
601        println!(
602            "Nothing new in {}: {} the same as on the server.",
603            link.slug,
604            count(out.unchanged.len(), "document")
605        );
606    } else {
607        let done = output::out().done;
608        anstream::println!(
609            "{done}Pulled{done:#} {} into {}",
610            link.slug,
611            shown(&link.root)
612        );
613        for d in &out.written {
614            println!("  wrote    {}{}", d.path, r(d));
615        }
616        for d in &out.deleted {
617            println!("  deleted  {}", d.path);
618        }
619        for d in &out.skipped {
620            println!(
621                "  skipped  {}: {}",
622                d.path,
623                d.note.as_deref().unwrap_or("skipped")
624            );
625        }
626        if !out.unchanged.is_empty() {
627            println!("  {} unchanged", count(out.unchanged.len(), "document"));
628        }
629    }
630    let local: Vec<&str> = out
631        .unchanged
632        .iter()
633        .filter(|d| d.note.is_some())
634        .map(|d| d.path.as_str())
635        .collect();
636    if !local.is_empty() {
637        println!("Changed here, not pushed yet: {}", local.join(", "));
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn scope_covers_the_path_what_is_under_it_and_a_bundle_that_holds_it() {
647        assert!(in_scope("docs/plan.md", None));
648        assert!(in_scope("docs/plan.md", Some("docs")));
649        assert!(in_scope("docs/plan.md", Some("docs/plan.md")));
650        assert!(in_scope("docs/site", Some("docs/site/index.md")));
651        assert!(!in_scope("docs-old/plan.md", Some("docs")));
652        assert!(!in_scope("docs/plan.md", Some("docs/plan")));
653        assert!(!in_scope("notes.md", Some("docs")));
654    }
655
656    #[test]
657    fn a_path_that_does_not_exist_yet_still_finds_its_project() {
658        let d = tempfile::tempdir().unwrap();
659        std::fs::write(
660            d.path().join(".semora"),
661            "server = \"https://a.example.com\"\nproject = \"p\"\n",
662        )
663        .unwrap();
664        let (link, prefix) = scope(Some(&d.path().join("docs/new/plan.md"))).unwrap();
665        assert_eq!(link.slug, "p");
666        assert_eq!(prefix.as_deref(), Some("docs/new/plan.md"));
667        let (_, whole) = scope(Some(d.path())).unwrap();
668        assert_eq!(whole, None);
669        let lonely = tempfile::tempdir().unwrap();
670        let err = scope(Some(lonely.path())).unwrap_err().to_string();
671        assert!(err.contains("project init"), "{err}");
672    }
673}