Skip to main content

rns_git/
work.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use rns_core::msgpack::{self, Value};
6
7use crate::acl::Operation;
8use crate::protocol;
9use crate::util::validate_repo_name;
10use crate::util::{hex, parse_hex_16};
11use crate::{Error, Result};
12
13pub const WORK_DOC_LIMIT: usize = 256 * 1024;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum WorkScope {
17    Active,
18    Completed,
19    Proposed,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum WorkListScope {
24    Active,
25    Completed,
26    Proposed,
27    All,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct WorkInput {
32    pub title: String,
33    pub content: String,
34    pub format: String,
35    pub signature: Option<Vec<u8>>,
36    pub identity: Option<Vec<u8>>,
37    pub author: [u8; 16],
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct WorkEdit {
42    pub title: Option<String>,
43    pub content: Option<String>,
44    pub signature: Option<Vec<u8>>,
45    pub identity: Option<Vec<u8>>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct WorkCommentInput {
50    pub content: String,
51    pub format: String,
52    pub signature: Option<Vec<u8>>,
53    pub author: [u8; 16],
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct WorkCreated {
58    pub id: u64,
59    pub scope: WorkScope,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct WorkSummary {
64    pub id: u64,
65    pub title: String,
66    pub created: u64,
67    pub edited: u64,
68    pub author: String,
69    pub format: String,
70    pub comments: u64,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Default)]
74pub struct WorkLists {
75    pub active: Vec<WorkSummary>,
76    pub completed: Vec<WorkSummary>,
77    pub proposed: Vec<WorkSummary>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct WorkDocument {
82    pub id: u64,
83    pub scope: WorkScope,
84    pub title: String,
85    pub content: String,
86    pub created: u64,
87    pub edited: u64,
88    pub author: String,
89    pub author_hash: [u8; 16],
90    pub format: String,
91    pub signature: Option<Vec<u8>>,
92    pub identity: Option<Vec<u8>>,
93    pub comments: Vec<WorkComment>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct WorkComment {
98    pub id: u64,
99    pub content: String,
100    pub created: u64,
101    pub edited: u64,
102    pub author: String,
103    pub author_hash: [u8; 16],
104    pub format: String,
105    pub signature: Option<Vec<u8>>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct WorkRequest {
110    pub repository: String,
111    pub operation: String,
112    pub step: Option<String>,
113    pub scope: Option<String>,
114    pub doc_id: Option<u64>,
115    pub title: Option<String>,
116    pub content: Option<String>,
117    pub format: Option<String>,
118    pub signature: Option<Vec<u8>>,
119}
120
121#[derive(Debug, Clone)]
122struct StoredDocument {
123    content: String,
124    title: Option<String>,
125    format: String,
126    created: u64,
127    edited: u64,
128    author: [u8; 16],
129    signature: Option<Vec<u8>>,
130    identity: Option<Vec<u8>>,
131}
132
133pub fn work_sidecar_path(repo: &Path) -> PathBuf {
134    let mut name = repo.as_os_str().to_os_string();
135    name.push(".work");
136    PathBuf::from(name)
137}
138
139pub fn parse_request(data: &[u8]) -> Result<WorkRequest> {
140    let value =
141        msgpack::unpack_exact(data).map_err(|e| Error::msg(format!("invalid msgpack: {e}")))?;
142    let map = value
143        .as_map()
144        .ok_or_else(|| Error::msg("request must be a msgpack map"))?;
145    let repository = map_get_repository(map)
146        .ok_or_else(|| Error::msg("request missing repository"))?
147        .to_string();
148    validate_repo_name(&repository)?;
149    let operation = map_get(map, "operation")
150        .and_then(Value::as_str)
151        .ok_or_else(|| Error::msg("request missing operation"))?
152        .to_string();
153    Ok(WorkRequest {
154        repository,
155        operation,
156        step: map_get(map, "step")
157            .and_then(Value::as_str)
158            .map(str::to_string),
159        scope: map_get(map, "scope")
160            .and_then(Value::as_str)
161            .map(str::to_string),
162        doc_id: map_get(map, "doc_id")
163            .and_then(Value::as_integer)
164            .filter(|value| *value >= 0)
165            .map(|value| value as u64),
166        title: map_get(map, "title")
167            .and_then(Value::as_str)
168            .map(str::to_string),
169        content: map_get(map, "content")
170            .and_then(Value::as_str)
171            .map(str::to_string),
172        format: map_get(map, "format")
173            .and_then(Value::as_str)
174            .map(str::to_string),
175        signature: map_get(map, "signature").and_then(value_to_signature),
176    })
177}
178
179pub fn list_response(work_path: &Path, scope: WorkListScope) -> Result<Vec<u8>> {
180    Ok(protocol::status_bytes(
181        protocol::RES_OK,
182        msgpack::pack(&lists_value(list_documents(work_path, scope)?)),
183    ))
184}
185
186pub fn view_response(work_path: &Path, scope: WorkScope, doc_id: u64) -> Result<Vec<u8>> {
187    match view_document(work_path, scope, doc_id)? {
188        Some(document) => Ok(protocol::status_bytes(
189            protocol::RES_OK,
190            msgpack::pack(&document_value_response(document)),
191        )),
192        None => Ok(protocol::status_bytes(
193            protocol::RES_NOT_FOUND,
194            b"document not found",
195        )),
196    }
197}
198
199pub fn created_response(created: WorkCreated) -> Vec<u8> {
200    protocol::status_bytes(
201        protocol::RES_OK,
202        msgpack::pack(&Value::Map(vec![
203            (Value::Str("id".into()), Value::UInt(created.id)),
204            (
205                Value::Str("scope".into()),
206                Value::Str(created.scope.as_str().into()),
207            ),
208        ])),
209    )
210}
211
212pub fn comment_response(comment_id: u64) -> Vec<u8> {
213    protocol::status_bytes(
214        protocol::RES_OK,
215        msgpack::pack(&Value::Map(vec![(
216            Value::Str("id".into()),
217            Value::UInt(comment_id),
218        )])),
219    )
220}
221
222pub fn transition_response(doc_id: u64, scope: WorkScope) -> Vec<u8> {
223    protocol::status_bytes(
224        protocol::RES_OK,
225        msgpack::pack(&Value::Map(vec![
226            (Value::Str("id".into()), Value::UInt(doc_id)),
227            (
228                Value::Str("scope".into()),
229                Value::Str(scope.as_str().into()),
230            ),
231        ])),
232    )
233}
234
235pub fn permissions_response(content: String) -> Vec<u8> {
236    protocol::status_bytes(
237        protocol::RES_OK,
238        msgpack::pack(&Value::Map(vec![(
239            Value::Str("content".into()),
240            Value::Str(content),
241        )])),
242    )
243}
244
245pub fn create_document(work_path: &Path, input: WorkInput) -> Result<WorkCreated> {
246    create_document_in_scope(work_path, WorkScope::Active, input)
247}
248
249pub fn propose_document(work_path: &Path, input: WorkInput) -> Result<WorkCreated> {
250    let author = input.author;
251    let created = create_document_in_scope(work_path, WorkScope::Proposed, input)?;
252    let permissions = format!("interact = {}\nwrite = {}\n", hex(&author), hex(&author));
253    set_document_permissions(work_path, created.id, &permissions)?;
254    Ok(created)
255}
256
257fn create_document_in_scope(
258    work_path: &Path,
259    scope: WorkScope,
260    input: WorkInput,
261) -> Result<WorkCreated> {
262    let title = non_empty_trimmed(&input.title, "title is required")?;
263    let content = non_empty_trimmed(&input.content, "content is required")?;
264    let format = normalize_format(&input.format);
265    validate_signature(input.signature.as_deref())?;
266    validate_limit(title.len() + content.len() + format.len())?;
267
268    let id = next_document_id(work_path)?;
269    let now = unix_time();
270    let document = StoredDocument {
271        content,
272        title: Some(title),
273        format,
274        created: now,
275        edited: now,
276        author: input.author,
277        signature: input.signature,
278        identity: input.identity,
279    };
280    let doc_dir = scope_dir(work_path, scope).join(id.to_string());
281    write_document(&doc_dir.join("root"), &document)?;
282    Ok(WorkCreated { id, scope })
283}
284
285pub fn list_documents(work_path: &Path, scope: WorkListScope) -> Result<WorkLists> {
286    let mut lists = WorkLists::default();
287    if matches!(scope, WorkListScope::Active | WorkListScope::All) {
288        lists.active = list_scope(work_path, WorkScope::Active)?;
289    }
290    if matches!(scope, WorkListScope::Completed | WorkListScope::All) {
291        lists.completed = list_scope(work_path, WorkScope::Completed)?;
292    }
293    if matches!(scope, WorkListScope::Proposed | WorkListScope::All) {
294        lists.proposed = list_scope(work_path, WorkScope::Proposed)?;
295    }
296    Ok(lists)
297}
298
299pub fn view_document(
300    work_path: &Path,
301    scope: WorkScope,
302    doc_id: u64,
303) -> Result<Option<WorkDocument>> {
304    let doc_dir = scope_dir(work_path, scope).join(doc_id.to_string());
305    let root_path = doc_dir.join("root");
306    if !root_path.is_file() {
307        return Ok(None);
308    }
309    let doc = read_document(&root_path)?;
310    Ok(Some(WorkDocument {
311        id: doc_id,
312        scope,
313        title: doc.title.unwrap_or_else(|| "Untitled".to_string()),
314        content: doc.content,
315        created: doc.created,
316        edited: doc.edited,
317        author: hex(&doc.author),
318        author_hash: doc.author,
319        format: doc.format,
320        signature: doc.signature,
321        identity: doc.identity,
322        comments: list_comments(&doc_dir)?,
323    }))
324}
325
326pub fn edit_document(
327    work_path: &Path,
328    scope: WorkScope,
329    doc_id: u64,
330    author: &[u8; 16],
331    edit: WorkEdit,
332) -> Result<()> {
333    let content_changed = edit.content.is_some();
334    if edit.title.is_none() && edit.content.is_none() {
335        return Err(Error::msg("no changes specified"));
336    }
337    validate_signature(edit.signature.as_deref())?;
338    let size = edit.title.as_deref().map(str::len).unwrap_or(0)
339        + edit.content.as_deref().map(str::len).unwrap_or(0);
340    validate_limit(size)?;
341
342    let root_path = root_path(work_path, scope, doc_id);
343    let mut doc = read_existing_document(&root_path)?;
344    ensure_author(&doc, author)?;
345    if let Some(title) = edit.title {
346        doc.title = Some(title.trim().to_string());
347    }
348    if let Some(content) = edit.content {
349        doc.content = content.trim().to_string();
350    }
351    doc.edited = unix_time();
352    if content_changed || edit.signature.is_some() || edit.identity.is_some() {
353        doc.signature = edit.signature;
354        doc.identity = edit.identity;
355    }
356    write_document(&root_path, &doc)
357}
358
359pub fn delete_document(
360    work_path: &Path,
361    scope: WorkScope,
362    doc_id: u64,
363    author: &[u8; 16],
364) -> Result<()> {
365    let doc_dir = scope_dir(work_path, scope).join(doc_id.to_string());
366    let doc = read_existing_document(&doc_dir.join("root"))?;
367    ensure_author(&doc, author)?;
368    fs::remove_dir_all(doc_dir)?;
369    Ok(())
370}
371
372pub fn add_comment(
373    work_path: &Path,
374    scope: WorkScope,
375    doc_id: u64,
376    input: WorkCommentInput,
377) -> Result<u64> {
378    let content = non_empty_trimmed(&input.content, "content is required")?;
379    let format = normalize_format(&input.format);
380    validate_signature(input.signature.as_deref())?;
381    validate_limit(content.len())?;
382
383    let doc_dir = scope_dir(work_path, scope).join(doc_id.to_string());
384    if !doc_dir.join("root").is_file() {
385        return Err(Error::msg("document not found"));
386    }
387    let comment_id = next_numeric_child(&doc_dir)?;
388    let now = unix_time();
389    let comment = StoredDocument {
390        content,
391        title: None,
392        format,
393        created: now,
394        edited: now,
395        author: input.author,
396        signature: input.signature,
397        identity: None,
398    };
399    write_document(&doc_dir.join(comment_id.to_string()), &comment)?;
400    Ok(comment_id)
401}
402
403pub fn complete_document(work_path: &Path, doc_id: u64, author: &[u8; 16]) -> Result<()> {
404    move_document(
405        work_path,
406        WorkScope::Active,
407        WorkScope::Completed,
408        doc_id,
409        author,
410    )
411}
412
413pub fn activate_document(work_path: &Path, doc_id: u64, author: &[u8; 16]) -> Result<()> {
414    move_document(
415        work_path,
416        WorkScope::Completed,
417        WorkScope::Active,
418        doc_id,
419        author,
420    )
421}
422
423pub fn accept_proposal(work_path: &Path, doc_id: u64, author: &[u8; 16]) -> Result<()> {
424    move_document(
425        work_path,
426        WorkScope::Proposed,
427        WorkScope::Active,
428        doc_id,
429        author,
430    )
431}
432
433pub fn document_author(work_path: &Path, doc_id: u64) -> Result<[u8; 16]> {
434    let doc_dir =
435        find_document_dir(work_path, doc_id)?.ok_or_else(|| Error::msg("document not found"))?;
436    Ok(read_existing_document(&doc_dir.join("root"))?.author)
437}
438
439pub fn document_permission_allows(
440    work_path: &Path,
441    doc_id: u64,
442    op: Operation,
443    identity: Option<&[u8; 16]>,
444) -> Result<bool> {
445    let path = permissions_path(work_path, doc_id);
446    if !path.is_file() {
447        return Ok(false);
448    }
449    let content = fs::read_to_string(path)?;
450    if op != Operation::Admin
451        && crate::acl::allowed_input_allows(&content, Operation::Admin, identity)?
452    {
453        return Ok(true);
454    }
455    crate::acl::allowed_input_allows(&content, op, identity)
456}
457
458pub fn get_document_permissions(work_path: &Path, doc_id: u64) -> Result<String> {
459    find_document_dir(work_path, doc_id)?.ok_or_else(|| Error::msg("document not found"))?;
460    let path = permissions_path(work_path, doc_id);
461    if path.is_file() {
462        Ok(fs::read_to_string(path)?)
463    } else {
464        Ok(String::new())
465    }
466}
467
468pub fn set_document_permissions(work_path: &Path, doc_id: u64, content: &str) -> Result<()> {
469    find_document_dir(work_path, doc_id)?.ok_or_else(|| Error::msg("document not found"))?;
470    crate::acl::validate_allowed_input(content)?;
471    fs::create_dir_all(work_path)?;
472    let path = permissions_path(work_path, doc_id);
473    let tmp = path.with_extension("allowed.tmp");
474    fs::write(&tmp, content)?;
475    fs::rename(tmp, path)?;
476    Ok(())
477}
478
479fn move_document(
480    work_path: &Path,
481    from: WorkScope,
482    to: WorkScope,
483    doc_id: u64,
484    author: &[u8; 16],
485) -> Result<()> {
486    let from_dir = scope_dir(work_path, from).join(doc_id.to_string());
487    let doc = read_existing_document(&from_dir.join("root"))?;
488    ensure_author(&doc, author)?;
489    let to_base = scope_dir(work_path, to);
490    fs::create_dir_all(&to_base)?;
491    fs::rename(from_dir, to_base.join(doc_id.to_string()))?;
492    Ok(())
493}
494
495fn list_scope(work_path: &Path, scope: WorkScope) -> Result<Vec<WorkSummary>> {
496    let mut out = Vec::new();
497    let folder = scope_dir(work_path, scope);
498    if !folder.is_dir() {
499        return Ok(out);
500    }
501    for entry in fs::read_dir(folder)? {
502        let entry = entry?;
503        if !entry.file_type()?.is_dir() {
504            continue;
505        }
506        let Some(id) = entry.file_name().to_string_lossy().parse::<u64>().ok() else {
507            continue;
508        };
509        let root_path = entry.path().join("root");
510        if !root_path.is_file() {
511            continue;
512        }
513        let doc = read_document(&root_path)?;
514        out.push(WorkSummary {
515            id,
516            title: doc.title.unwrap_or_else(|| "Untitled".to_string()),
517            created: doc.created,
518            edited: doc.edited,
519            author: hex(&doc.author),
520            format: doc.format,
521            comments: comment_count(&entry.path())?,
522        });
523    }
524    out.sort_by(|a, b| {
525        b.created
526            .max(b.edited)
527            .cmp(&a.created.max(a.edited))
528            .then_with(|| b.id.cmp(&a.id))
529    });
530    Ok(out)
531}
532
533fn list_comments(doc_dir: &Path) -> Result<Vec<WorkComment>> {
534    let mut comments = Vec::new();
535    if !doc_dir.is_dir() {
536        return Ok(comments);
537    }
538    for entry in fs::read_dir(doc_dir)? {
539        let entry = entry?;
540        if !entry.file_type()?.is_file() {
541            continue;
542        }
543        let Some(id) = entry.file_name().to_string_lossy().parse::<u64>().ok() else {
544            continue;
545        };
546        let comment = read_document(&entry.path())?;
547        comments.push(WorkComment {
548            id,
549            content: comment.content,
550            created: comment.created,
551            edited: comment.edited,
552            author: hex(&comment.author),
553            author_hash: comment.author,
554            format: comment.format,
555            signature: comment.signature,
556        });
557    }
558    comments.sort_by_key(|comment| comment.id);
559    Ok(comments)
560}
561
562fn comment_count(doc_dir: &Path) -> Result<u64> {
563    Ok(list_comments(doc_dir)?.len() as u64)
564}
565
566fn next_document_id(work_path: &Path) -> Result<u64> {
567    Ok(
568        next_numeric_child(&scope_dir(work_path, WorkScope::Active))?
569            .max(next_numeric_child(&scope_dir(
570                work_path,
571                WorkScope::Completed,
572            ))?)
573            .max(next_numeric_child(&scope_dir(
574                work_path,
575                WorkScope::Proposed,
576            ))?),
577    )
578}
579
580fn next_numeric_child(path: &Path) -> Result<u64> {
581    if !path.is_dir() {
582        return Ok(1);
583    }
584    let mut max_id = 0;
585    for entry in fs::read_dir(path)? {
586        let entry = entry?;
587        if let Ok(id) = entry.file_name().to_string_lossy().parse::<u64>() {
588            max_id = max_id.max(id);
589        }
590    }
591    Ok(max_id + 1)
592}
593
594fn root_path(work_path: &Path, scope: WorkScope, doc_id: u64) -> PathBuf {
595    scope_dir(work_path, scope)
596        .join(doc_id.to_string())
597        .join("root")
598}
599
600fn scope_dir(work_path: &Path, scope: WorkScope) -> PathBuf {
601    work_path.join(scope.as_str())
602}
603
604fn permissions_path(work_path: &Path, doc_id: u64) -> PathBuf {
605    work_path.join(format!("{doc_id}.allowed"))
606}
607
608pub fn find_document_scope(work_path: &Path, doc_id: u64) -> Result<Option<WorkScope>> {
609    for scope in [WorkScope::Active, WorkScope::Completed, WorkScope::Proposed] {
610        let doc_dir = scope_dir(work_path, scope).join(doc_id.to_string());
611        if doc_dir.join("root").is_file() {
612            return Ok(Some(scope));
613        }
614    }
615    Ok(None)
616}
617
618fn find_document_dir(work_path: &Path, doc_id: u64) -> Result<Option<PathBuf>> {
619    Ok(find_document_scope(work_path, doc_id)?
620        .map(|scope| scope_dir(work_path, scope).join(doc_id.to_string())))
621}
622
623impl WorkScope {
624    pub fn parse(value: &str) -> Option<Self> {
625        match value {
626            "active" => Some(WorkScope::Active),
627            "completed" => Some(WorkScope::Completed),
628            "proposed" => Some(WorkScope::Proposed),
629            _ => None,
630        }
631    }
632
633    pub fn as_str(self) -> &'static str {
634        match self {
635            WorkScope::Active => "active",
636            WorkScope::Completed => "completed",
637            WorkScope::Proposed => "proposed",
638        }
639    }
640}
641
642impl WorkListScope {
643    pub fn parse(value: &str) -> Option<Self> {
644        match value {
645            "active" => Some(WorkListScope::Active),
646            "completed" => Some(WorkListScope::Completed),
647            "proposed" => Some(WorkListScope::Proposed),
648            "all" => Some(WorkListScope::All),
649            _ => None,
650        }
651    }
652}
653
654fn read_existing_document(path: &Path) -> Result<StoredDocument> {
655    if !path.is_file() {
656        return Err(Error::msg("document not found"));
657    }
658    read_document(path)
659}
660
661fn read_document(path: &Path) -> Result<StoredDocument> {
662    let bytes = fs::read(path)?;
663    let value =
664        msgpack::unpack_exact(&bytes).map_err(|e| Error::msg(format!("invalid msgpack: {e}")))?;
665    stored_document_from_value(&value)
666}
667
668fn write_document(path: &Path, document: &StoredDocument) -> Result<()> {
669    let parent = path
670        .parent()
671        .ok_or_else(|| Error::msg("document path has no parent"))?;
672    fs::create_dir_all(parent)?;
673    let tmp = path.with_extension("tmp");
674    fs::write(&tmp, msgpack::pack(&document_value(document)))?;
675    fs::rename(tmp, path)?;
676    Ok(())
677}
678
679fn document_value(document: &StoredDocument) -> Value {
680    Value::Map(vec![
681        (
682            Value::Str("content".into()),
683            Value::Str(document.content.clone()),
684        ),
685        (
686            Value::Str("meta".into()),
687            Value::Map(vec![
688                (
689                    Value::Str("format".into()),
690                    Value::Str(document.format.clone()),
691                ),
692                (
693                    Value::Str("title".into()),
694                    document.title.clone().map(Value::Str).unwrap_or(Value::Nil),
695                ),
696                (Value::Str("created".into()), Value::UInt(document.created)),
697                (Value::Str("edited".into()), Value::UInt(document.edited)),
698                (
699                    Value::Str("signature".into()),
700                    document
701                        .signature
702                        .clone()
703                        .map(Value::Bin)
704                        .unwrap_or(Value::Nil),
705                ),
706                (
707                    Value::Str("identity".into()),
708                    document
709                        .identity
710                        .clone()
711                        .map(Value::Bin)
712                        .unwrap_or(Value::Nil),
713                ),
714                (
715                    Value::Str("author".into()),
716                    Value::Bin(document.author.to_vec()),
717                ),
718            ]),
719        ),
720    ])
721}
722
723fn lists_value(lists: WorkLists) -> Value {
724    Value::Map(vec![
725        (
726            Value::Str("active".into()),
727            Value::Array(lists.active.into_iter().map(summary_value).collect()),
728        ),
729        (
730            Value::Str("completed".into()),
731            Value::Array(lists.completed.into_iter().map(summary_value).collect()),
732        ),
733        (
734            Value::Str("proposed".into()),
735            Value::Array(lists.proposed.into_iter().map(summary_value).collect()),
736        ),
737    ])
738}
739
740fn summary_value(summary: WorkSummary) -> Value {
741    Value::Map(vec![
742        (Value::Str("id".into()), Value::UInt(summary.id)),
743        (Value::Str("title".into()), Value::Str(summary.title)),
744        (Value::Str("created".into()), Value::UInt(summary.created)),
745        (Value::Str("edited".into()), Value::UInt(summary.edited)),
746        (Value::Str("author".into()), Value::Str(summary.author)),
747        (Value::Str("format".into()), Value::Str(summary.format)),
748        (Value::Str("comments".into()), Value::UInt(summary.comments)),
749    ])
750}
751
752fn document_value_response(document: WorkDocument) -> Value {
753    Value::Map(vec![
754        (Value::Str("id".into()), Value::UInt(document.id)),
755        (
756            Value::Str("scope".into()),
757            Value::Str(document.scope.as_str().into()),
758        ),
759        (Value::Str("content".into()), Value::Str(document.content)),
760        (
761            Value::Str("comments".into()),
762            Value::Array(document.comments.into_iter().map(comment_value).collect()),
763        ),
764        (
765            Value::Str("meta".into()),
766            Value::Map(vec![
767                (Value::Str("title".into()), Value::Str(document.title)),
768                (Value::Str("created".into()), Value::UInt(document.created)),
769                (Value::Str("edited".into()), Value::UInt(document.edited)),
770                (Value::Str("author".into()), Value::Str(document.author)),
771                (
772                    Value::Str("identity".into()),
773                    document.identity.map(Value::Bin).unwrap_or(Value::Nil),
774                ),
775                (
776                    Value::Str("signature".into()),
777                    document.signature.map(Value::Bin).unwrap_or(Value::Nil),
778                ),
779                (Value::Str("format".into()), Value::Str(document.format)),
780            ]),
781        ),
782    ])
783}
784
785fn comment_value(comment: WorkComment) -> Value {
786    Value::Map(vec![
787        (Value::Str("id".into()), Value::UInt(comment.id)),
788        (Value::Str("content".into()), Value::Str(comment.content)),
789        (Value::Str("created".into()), Value::UInt(comment.created)),
790        (Value::Str("edited".into()), Value::UInt(comment.edited)),
791        (Value::Str("author".into()), Value::Str(comment.author)),
792        (Value::Str("format".into()), Value::Str(comment.format)),
793    ])
794}
795
796fn stored_document_from_value(value: &Value) -> Result<StoredDocument> {
797    let content = value
798        .map_get("content")
799        .and_then(Value::as_str)
800        .unwrap_or("")
801        .to_string();
802    let meta = value
803        .map_get("meta")
804        .and_then(Value::as_map)
805        .ok_or_else(|| Error::msg("work document missing metadata"))?;
806    let title = map_get(meta, "title")
807        .and_then(Value::as_str)
808        .map(str::to_string);
809    let format = map_get(meta, "format")
810        .and_then(Value::as_str)
811        .map(normalize_format)
812        .unwrap_or_else(|| "markdown".to_string());
813    let created = map_get(meta, "created")
814        .and_then(Value::as_number)
815        .unwrap_or(0.0)
816        .max(0.0) as u64;
817    let edited = map_get(meta, "edited")
818        .and_then(Value::as_number)
819        .unwrap_or(0.0)
820        .max(0.0) as u64;
821    let author = map_get(meta, "author")
822        .and_then(value_to_author)
823        .unwrap_or([0; 16]);
824    let signature = map_get(meta, "signature").and_then(value_to_signature);
825    let identity = map_get(meta, "identity").and_then(value_to_identity);
826    Ok(StoredDocument {
827        content,
828        title,
829        format,
830        created,
831        edited,
832        author,
833        signature,
834        identity,
835    })
836}
837
838fn map_get<'a>(map: &'a [(Value, Value)], key: &str) -> Option<&'a Value> {
839    map.iter().find_map(|(map_key, value)| match map_key {
840        Value::Str(map_key) if map_key == key => Some(value),
841        _ => None,
842    })
843}
844
845fn map_get_repository(map: &[(Value, Value)]) -> Option<&str> {
846    map.iter().find_map(|(key, value)| match key {
847        Value::UInt(v) if *v == protocol::IDX_REPOSITORY => value.as_str(),
848        Value::Str(v) if v == "repository" => value.as_str(),
849        _ => None,
850    })
851}
852
853fn value_to_author(value: &Value) -> Option<[u8; 16]> {
854    match value {
855        Value::Bin(bytes) if bytes.len() == 16 => bytes.as_slice().try_into().ok(),
856        Value::Str(value) => parse_hex_16(value).ok(),
857        _ => None,
858    }
859}
860
861fn value_to_signature(value: &Value) -> Option<Vec<u8>> {
862    match value {
863        Value::Bin(bytes) => Some(bytes.clone()),
864        _ => None,
865    }
866}
867
868fn value_to_identity(value: &Value) -> Option<Vec<u8>> {
869    match value {
870        Value::Bin(bytes) if bytes.len() == 64 => Some(bytes.clone()),
871        _ => None,
872    }
873}
874
875fn normalize_format(value: &str) -> String {
876    match value {
877        "micron" => "micron".to_string(),
878        _ => "markdown".to_string(),
879    }
880}
881
882fn non_empty_trimmed(value: &str, message: &str) -> Result<String> {
883    let value = value.trim();
884    if value.is_empty() {
885        Err(Error::msg(message))
886    } else {
887        Ok(value.to_string())
888    }
889}
890
891fn validate_limit(size: usize) -> Result<()> {
892    if size > WORK_DOC_LIMIT {
893        Err(Error::msg("content limit exceeded"))
894    } else {
895        Ok(())
896    }
897}
898
899fn validate_signature(signature: Option<&[u8]>) -> Result<()> {
900    if signature.is_some_and(|signature| signature.len() != 64) {
901        Err(Error::msg("invalid signature"))
902    } else {
903        Ok(())
904    }
905}
906
907fn ensure_author(document: &StoredDocument, author: &[u8; 16]) -> Result<()> {
908    if &document.author == author {
909        Ok(())
910    } else {
911        Err(Error::msg("no access, not author"))
912    }
913}
914
915fn unix_time() -> u64 {
916    SystemTime::now()
917        .duration_since(UNIX_EPOCH)
918        .unwrap_or_default()
919        .as_secs()
920}
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925
926    const AUTHOR: [u8; 16] = [0x11; 16];
927    const OTHER: [u8; 16] = [0x22; 16];
928
929    #[test]
930    fn work_sidecar_path_appends_work_extension_to_repository_path() {
931        assert_eq!(
932            work_sidecar_path(Path::new("/tmp/repos/group/repo")),
933            PathBuf::from("/tmp/repos/group/repo.work")
934        );
935    }
936
937    #[test]
938    fn create_list_and_view_document_round_trip_msgpack_storage() {
939        let tmp = tempfile::tempdir().unwrap();
940        let work_path = tmp.path().join("repo.work");
941
942        let created = create_document(
943            &work_path,
944            WorkInput {
945                title: "  First task  ".into(),
946                content: "  Do the thing  ".into(),
947                format: "unknown".into(),
948                signature: None,
949                identity: None,
950                author: AUTHOR,
951            },
952        )
953        .unwrap();
954        assert_eq!(
955            created,
956            WorkCreated {
957                id: 1,
958                scope: WorkScope::Active
959            }
960        );
961        assert!(work_path.join("active/1/root").is_file());
962
963        let raw = fs::read(work_path.join("active/1/root")).unwrap();
964        let stored = msgpack::unpack_exact(&raw).unwrap();
965        assert_eq!(
966            stored.map_get("content").and_then(Value::as_str),
967            Some("Do the thing")
968        );
969        assert_eq!(
970            stored
971                .map_get("meta")
972                .and_then(Value::as_map)
973                .and_then(|meta| map_get(meta, "format"))
974                .and_then(Value::as_str),
975            Some("markdown")
976        );
977
978        let lists = list_documents(&work_path, WorkListScope::All).unwrap();
979        assert_eq!(lists.active.len(), 1);
980        assert_eq!(lists.completed.len(), 0);
981        assert_eq!(lists.active[0].title, "First task");
982        assert_eq!(lists.active[0].author, "11111111111111111111111111111111");
983        assert_eq!(lists.active[0].comments, 0);
984
985        let document = view_document(&work_path, WorkScope::Active, 1)
986            .unwrap()
987            .unwrap();
988        assert_eq!(document.title, "First task");
989        assert_eq!(document.content, "Do the thing");
990        assert_eq!(document.format, "markdown");
991        assert_eq!(document.author_hash, AUTHOR);
992        assert!(document.comments.is_empty());
993    }
994
995    #[test]
996    fn document_ids_are_allocated_across_active_and_completed_scopes() {
997        let tmp = tempfile::tempdir().unwrap();
998        let work_path = tmp.path().join("repo.work");
999
1000        assert_eq!(create_sample(&work_path, "one").unwrap().id, 1);
1001        complete_document(&work_path, 1, &AUTHOR).unwrap();
1002        assert_eq!(create_sample(&work_path, "two").unwrap().id, 2);
1003
1004        let lists = list_documents(&work_path, WorkListScope::All).unwrap();
1005        assert_eq!(lists.active[0].id, 2);
1006        assert_eq!(lists.completed[0].id, 1);
1007    }
1008
1009    #[test]
1010    fn document_lists_sort_by_latest_created_or_edited_activity() {
1011        let tmp = tempfile::tempdir().unwrap();
1012        let work_path = tmp.path().join("repo.work");
1013
1014        assert_eq!(create_sample(&work_path, "older edited").unwrap().id, 1);
1015        assert_eq!(create_sample(&work_path, "newer untouched").unwrap().id, 2);
1016        let mut first = read_document(&root_path(&work_path, WorkScope::Active, 1)).unwrap();
1017        first.edited = first.created + 10_000;
1018        write_document(&root_path(&work_path, WorkScope::Active, 1), &first).unwrap();
1019
1020        let lists = list_documents(&work_path, WorkListScope::Active).unwrap();
1021        let ids: Vec<_> = lists.active.iter().map(|doc| doc.id).collect();
1022        assert_eq!(ids, vec![1, 2]);
1023    }
1024
1025    #[test]
1026    fn comments_are_numbered_counted_and_sorted() {
1027        let tmp = tempfile::tempdir().unwrap();
1028        let work_path = tmp.path().join("repo.work");
1029        create_sample(&work_path, "one").unwrap();
1030
1031        fs::write(
1032            work_path.join("active/1/9"),
1033            fs::read(work_path.join("active/1/root")).unwrap(),
1034        )
1035        .unwrap();
1036        let comment_id = add_comment(
1037            &work_path,
1038            WorkScope::Active,
1039            1,
1040            WorkCommentInput {
1041                content: "  update  ".into(),
1042                format: "micron".into(),
1043                signature: Some(vec![0xAA; 64]),
1044                author: OTHER,
1045            },
1046        )
1047        .unwrap();
1048        assert_eq!(comment_id, 10);
1049
1050        let lists = list_documents(&work_path, WorkListScope::Active).unwrap();
1051        assert_eq!(lists.active[0].comments, 2);
1052
1053        let document = view_document(&work_path, WorkScope::Active, 1)
1054            .unwrap()
1055            .unwrap();
1056        let ids: Vec<_> = document.comments.iter().map(|comment| comment.id).collect();
1057        assert_eq!(ids, vec![9, 10]);
1058        assert_eq!(document.comments[1].content, "update");
1059        assert_eq!(document.comments[1].format, "micron");
1060        assert_eq!(document.comments[1].signature, Some(vec![0xAA; 64]));
1061        assert_eq!(document.comments[1].author_hash, OTHER);
1062    }
1063
1064    #[test]
1065    fn edit_delete_complete_and_activate_require_document_author() {
1066        let tmp = tempfile::tempdir().unwrap();
1067        let work_path = tmp.path().join("repo.work");
1068        create_sample(&work_path, "one").unwrap();
1069
1070        assert!(edit_document(
1071            &work_path,
1072            WorkScope::Active,
1073            1,
1074            &OTHER,
1075            WorkEdit {
1076                title: Some("nope".into()),
1077                content: None,
1078                signature: None,
1079                identity: None,
1080            },
1081        )
1082        .is_err());
1083        edit_document(
1084            &work_path,
1085            WorkScope::Active,
1086            1,
1087            &AUTHOR,
1088            WorkEdit {
1089                title: Some("  updated title ".into()),
1090                content: Some(" updated content ".into()),
1091                signature: Some(vec![0x55; 64]),
1092                identity: Some(vec![0x66; 64]),
1093            },
1094        )
1095        .unwrap();
1096        let document = view_document(&work_path, WorkScope::Active, 1)
1097            .unwrap()
1098            .unwrap();
1099        assert_eq!(document.title, "updated title");
1100        assert_eq!(document.content, "updated content");
1101        assert_eq!(document.signature, Some(vec![0x55; 64]));
1102        assert_eq!(document.identity, Some(vec![0x66; 64]));
1103
1104        assert!(complete_document(&work_path, 1, &OTHER).is_err());
1105        complete_document(&work_path, 1, &AUTHOR).unwrap();
1106        assert!(view_document(&work_path, WorkScope::Active, 1)
1107            .unwrap()
1108            .is_none());
1109        assert!(view_document(&work_path, WorkScope::Completed, 1)
1110            .unwrap()
1111            .is_some());
1112
1113        assert!(activate_document(&work_path, 1, &OTHER).is_err());
1114        activate_document(&work_path, 1, &AUTHOR).unwrap();
1115        assert!(view_document(&work_path, WorkScope::Completed, 1)
1116            .unwrap()
1117            .is_none());
1118        assert!(view_document(&work_path, WorkScope::Active, 1)
1119            .unwrap()
1120            .is_some());
1121
1122        assert!(delete_document(&work_path, WorkScope::Active, 1, &OTHER).is_err());
1123        delete_document(&work_path, WorkScope::Active, 1, &AUTHOR).unwrap();
1124        assert!(view_document(&work_path, WorkScope::Active, 1)
1125            .unwrap()
1126            .is_none());
1127    }
1128
1129    #[test]
1130    fn rejects_invalid_document_input() {
1131        let tmp = tempfile::tempdir().unwrap();
1132        let work_path = tmp.path().join("repo.work");
1133
1134        assert!(create_document(
1135            &work_path,
1136            WorkInput {
1137                title: " ".into(),
1138                content: "content".into(),
1139                format: "markdown".into(),
1140                signature: None,
1141                identity: None,
1142                author: AUTHOR,
1143            },
1144        )
1145        .is_err());
1146        assert!(create_document(
1147            &work_path,
1148            WorkInput {
1149                title: "title".into(),
1150                content: " ".into(),
1151                format: "markdown".into(),
1152                signature: None,
1153                identity: None,
1154                author: AUTHOR,
1155            },
1156        )
1157        .is_err());
1158        assert!(create_document(
1159            &work_path,
1160            WorkInput {
1161                title: "title".into(),
1162                content: "content".into(),
1163                format: "markdown".into(),
1164                signature: Some(vec![0; 63]),
1165                identity: None,
1166                author: AUTHOR,
1167            },
1168        )
1169        .is_err());
1170    }
1171
1172    #[test]
1173    fn document_content_limit_matches_upstream_256_kib() {
1174        assert_eq!(WORK_DOC_LIMIT, 256 * 1024);
1175
1176        let tmp = tempfile::tempdir().unwrap();
1177        let work_path = tmp.path().join("repo.work");
1178        let title = "title";
1179        let format = "markdown";
1180        let at_limit = "x".repeat(WORK_DOC_LIMIT - title.len() - format.len());
1181        let over_limit = "x".repeat(WORK_DOC_LIMIT - title.len() - format.len() + 1);
1182
1183        create_document(
1184            &work_path,
1185            WorkInput {
1186                title: title.into(),
1187                content: at_limit,
1188                format: format.into(),
1189                signature: None,
1190                identity: None,
1191                author: AUTHOR,
1192            },
1193        )
1194        .unwrap();
1195
1196        let err = create_document(
1197            &work_path,
1198            WorkInput {
1199                title: title.into(),
1200                content: over_limit,
1201                format: format.into(),
1202                signature: None,
1203                identity: None,
1204                author: AUTHOR,
1205            },
1206        )
1207        .unwrap_err();
1208        assert_eq!(err.to_string(), "content limit exceeded");
1209    }
1210
1211    fn create_sample(work_path: &Path, title: &str) -> Result<WorkCreated> {
1212        create_document(
1213            work_path,
1214            WorkInput {
1215                title: title.into(),
1216                content: "content".into(),
1217                format: "markdown".into(),
1218                signature: None,
1219                identity: None,
1220                author: AUTHOR,
1221            },
1222        )
1223    }
1224}