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    complete_document_authorized(work_path, doc_id, author, false)
405}
406
407pub(crate) fn complete_document_authorized(
408    work_path: &Path,
409    doc_id: u64,
410    author: &[u8; 16],
411    admin: bool,
412) -> Result<()> {
413    move_document(
414        work_path,
415        WorkScope::Active,
416        WorkScope::Completed,
417        doc_id,
418        author,
419        admin,
420    )
421}
422
423pub fn activate_document(work_path: &Path, doc_id: u64, author: &[u8; 16]) -> Result<()> {
424    activate_document_authorized(work_path, doc_id, author, false)
425}
426
427pub(crate) fn activate_document_authorized(
428    work_path: &Path,
429    doc_id: u64,
430    author: &[u8; 16],
431    admin: bool,
432) -> Result<()> {
433    let source = [WorkScope::Completed, WorkScope::Proposed]
434        .into_iter()
435        .find(|scope| {
436            scope_dir(work_path, *scope)
437                .join(doc_id.to_string())
438                .is_dir()
439        })
440        .ok_or_else(|| Error::msg("document not found"))?;
441    move_document(work_path, source, WorkScope::Active, doc_id, author, admin)
442}
443
444pub fn accept_proposal(work_path: &Path, doc_id: u64, author: &[u8; 16]) -> Result<()> {
445    move_document(
446        work_path,
447        WorkScope::Proposed,
448        WorkScope::Active,
449        doc_id,
450        author,
451        false,
452    )
453}
454
455pub fn document_author(work_path: &Path, doc_id: u64) -> Result<[u8; 16]> {
456    let doc_dir =
457        find_document_dir(work_path, doc_id)?.ok_or_else(|| Error::msg("document not found"))?;
458    Ok(read_existing_document(&doc_dir.join("root"))?.author)
459}
460
461pub fn document_permission_allows(
462    work_path: &Path,
463    doc_id: u64,
464    op: Operation,
465    identity: Option<&[u8; 16]>,
466) -> Result<bool> {
467    let path = permissions_path(work_path, doc_id);
468    if !path.is_file() {
469        return Ok(false);
470    }
471    let content = fs::read_to_string(path)?;
472    if op != Operation::Admin
473        && crate::acl::allowed_input_allows(&content, Operation::Admin, identity)?
474    {
475        return Ok(true);
476    }
477    crate::acl::allowed_input_allows(&content, op, identity)
478}
479
480pub fn get_document_permissions(work_path: &Path, doc_id: u64) -> Result<String> {
481    find_document_dir(work_path, doc_id)?.ok_or_else(|| Error::msg("document not found"))?;
482    let path = permissions_path(work_path, doc_id);
483    if path.is_file() {
484        Ok(fs::read_to_string(path)?)
485    } else {
486        Ok(String::new())
487    }
488}
489
490pub(crate) fn document_permission_denies(
491    work_path: &Path,
492    doc_id: u64,
493    op: Operation,
494) -> Result<bool> {
495    let path = permissions_path(work_path, doc_id);
496    if !path.is_file() {
497        return Ok(false);
498    }
499    crate::acl::allowed_input_denies(&fs::read_to_string(path)?, op)
500}
501
502pub fn set_document_permissions(work_path: &Path, doc_id: u64, content: &str) -> Result<()> {
503    find_document_dir(work_path, doc_id)?.ok_or_else(|| Error::msg("document not found"))?;
504    crate::acl::validate_allowed_input(content)?;
505    fs::create_dir_all(work_path)?;
506    let path = permissions_path(work_path, doc_id);
507    let tmp = path.with_extension("allowed.tmp");
508    fs::write(&tmp, content)?;
509    fs::rename(tmp, path)?;
510    Ok(())
511}
512
513fn move_document(
514    work_path: &Path,
515    from: WorkScope,
516    to: WorkScope,
517    doc_id: u64,
518    author: &[u8; 16],
519    admin: bool,
520) -> Result<()> {
521    let from_dir = scope_dir(work_path, from).join(doc_id.to_string());
522    let doc = read_existing_document(&from_dir.join("root"))?;
523    if !admin {
524        ensure_author(&doc, author)?;
525    }
526    let to_base = scope_dir(work_path, to);
527    fs::create_dir_all(&to_base)?;
528    fs::rename(from_dir, to_base.join(doc_id.to_string()))?;
529    Ok(())
530}
531
532fn list_scope(work_path: &Path, scope: WorkScope) -> Result<Vec<WorkSummary>> {
533    let mut out = Vec::new();
534    let folder = scope_dir(work_path, scope);
535    if !folder.is_dir() {
536        return Ok(out);
537    }
538    for entry in fs::read_dir(folder)? {
539        let entry = entry?;
540        if !entry.file_type()?.is_dir() {
541            continue;
542        }
543        let Some(id) = entry.file_name().to_string_lossy().parse::<u64>().ok() else {
544            continue;
545        };
546        let root_path = entry.path().join("root");
547        if !root_path.is_file() {
548            continue;
549        }
550        let doc = read_document(&root_path)?;
551        out.push(WorkSummary {
552            id,
553            title: doc.title.unwrap_or_else(|| "Untitled".to_string()),
554            created: doc.created,
555            edited: doc.edited,
556            author: hex(&doc.author),
557            format: doc.format,
558            comments: comment_count(&entry.path())?,
559        });
560    }
561    out.sort_by(|a, b| {
562        b.created
563            .max(b.edited)
564            .cmp(&a.created.max(a.edited))
565            .then_with(|| b.id.cmp(&a.id))
566    });
567    Ok(out)
568}
569
570fn list_comments(doc_dir: &Path) -> Result<Vec<WorkComment>> {
571    let mut comments = Vec::new();
572    if !doc_dir.is_dir() {
573        return Ok(comments);
574    }
575    for entry in fs::read_dir(doc_dir)? {
576        let entry = entry?;
577        if !entry.file_type()?.is_file() {
578            continue;
579        }
580        let Some(id) = entry.file_name().to_string_lossy().parse::<u64>().ok() else {
581            continue;
582        };
583        let comment = read_document(&entry.path())?;
584        comments.push(WorkComment {
585            id,
586            content: comment.content,
587            created: comment.created,
588            edited: comment.edited,
589            author: hex(&comment.author),
590            author_hash: comment.author,
591            format: comment.format,
592            signature: comment.signature,
593        });
594    }
595    comments.sort_by_key(|comment| comment.id);
596    Ok(comments)
597}
598
599fn comment_count(doc_dir: &Path) -> Result<u64> {
600    Ok(list_comments(doc_dir)?.len() as u64)
601}
602
603fn next_document_id(work_path: &Path) -> Result<u64> {
604    Ok(
605        next_numeric_child(&scope_dir(work_path, WorkScope::Active))?
606            .max(next_numeric_child(&scope_dir(
607                work_path,
608                WorkScope::Completed,
609            ))?)
610            .max(next_numeric_child(&scope_dir(
611                work_path,
612                WorkScope::Proposed,
613            ))?),
614    )
615}
616
617fn next_numeric_child(path: &Path) -> Result<u64> {
618    if !path.is_dir() {
619        return Ok(1);
620    }
621    let mut max_id = 0;
622    for entry in fs::read_dir(path)? {
623        let entry = entry?;
624        if let Ok(id) = entry.file_name().to_string_lossy().parse::<u64>() {
625            max_id = max_id.max(id);
626        }
627    }
628    Ok(max_id + 1)
629}
630
631fn root_path(work_path: &Path, scope: WorkScope, doc_id: u64) -> PathBuf {
632    scope_dir(work_path, scope)
633        .join(doc_id.to_string())
634        .join("root")
635}
636
637fn scope_dir(work_path: &Path, scope: WorkScope) -> PathBuf {
638    work_path.join(scope.as_str())
639}
640
641fn permissions_path(work_path: &Path, doc_id: u64) -> PathBuf {
642    work_path.join(format!("{doc_id}.allowed"))
643}
644
645pub fn find_document_scope(work_path: &Path, doc_id: u64) -> Result<Option<WorkScope>> {
646    for scope in [WorkScope::Active, WorkScope::Completed, WorkScope::Proposed] {
647        let doc_dir = scope_dir(work_path, scope).join(doc_id.to_string());
648        if doc_dir.join("root").is_file() {
649            return Ok(Some(scope));
650        }
651    }
652    Ok(None)
653}
654
655fn find_document_dir(work_path: &Path, doc_id: u64) -> Result<Option<PathBuf>> {
656    Ok(find_document_scope(work_path, doc_id)?
657        .map(|scope| scope_dir(work_path, scope).join(doc_id.to_string())))
658}
659
660impl WorkScope {
661    pub fn parse(value: &str) -> Option<Self> {
662        match value {
663            "active" => Some(WorkScope::Active),
664            "completed" => Some(WorkScope::Completed),
665            "proposed" => Some(WorkScope::Proposed),
666            _ => None,
667        }
668    }
669
670    pub fn as_str(self) -> &'static str {
671        match self {
672            WorkScope::Active => "active",
673            WorkScope::Completed => "completed",
674            WorkScope::Proposed => "proposed",
675        }
676    }
677}
678
679impl WorkListScope {
680    pub fn parse(value: &str) -> Option<Self> {
681        match value {
682            "active" => Some(WorkListScope::Active),
683            "completed" => Some(WorkListScope::Completed),
684            "proposed" => Some(WorkListScope::Proposed),
685            "all" => Some(WorkListScope::All),
686            _ => None,
687        }
688    }
689}
690
691fn read_existing_document(path: &Path) -> Result<StoredDocument> {
692    if !path.is_file() {
693        return Err(Error::msg("document not found"));
694    }
695    read_document(path)
696}
697
698fn read_document(path: &Path) -> Result<StoredDocument> {
699    let bytes = fs::read(path)?;
700    let value =
701        msgpack::unpack_exact(&bytes).map_err(|e| Error::msg(format!("invalid msgpack: {e}")))?;
702    stored_document_from_value(&value)
703}
704
705fn write_document(path: &Path, document: &StoredDocument) -> Result<()> {
706    let parent = path
707        .parent()
708        .ok_or_else(|| Error::msg("document path has no parent"))?;
709    fs::create_dir_all(parent)?;
710    let tmp = path.with_extension("tmp");
711    fs::write(&tmp, msgpack::pack(&document_value(document)))?;
712    fs::rename(tmp, path)?;
713    Ok(())
714}
715
716fn document_value(document: &StoredDocument) -> Value {
717    Value::Map(vec![
718        (
719            Value::Str("content".into()),
720            Value::Str(document.content.clone()),
721        ),
722        (
723            Value::Str("meta".into()),
724            Value::Map(vec![
725                (
726                    Value::Str("format".into()),
727                    Value::Str(document.format.clone()),
728                ),
729                (
730                    Value::Str("title".into()),
731                    document.title.clone().map(Value::Str).unwrap_or(Value::Nil),
732                ),
733                (Value::Str("created".into()), Value::UInt(document.created)),
734                (Value::Str("edited".into()), Value::UInt(document.edited)),
735                (
736                    Value::Str("signature".into()),
737                    document
738                        .signature
739                        .clone()
740                        .map(Value::Bin)
741                        .unwrap_or(Value::Nil),
742                ),
743                (
744                    Value::Str("identity".into()),
745                    document
746                        .identity
747                        .clone()
748                        .map(Value::Bin)
749                        .unwrap_or(Value::Nil),
750                ),
751                (
752                    Value::Str("author".into()),
753                    Value::Bin(document.author.to_vec()),
754                ),
755            ]),
756        ),
757    ])
758}
759
760fn lists_value(lists: WorkLists) -> Value {
761    Value::Map(vec![
762        (
763            Value::Str("active".into()),
764            Value::Array(lists.active.into_iter().map(summary_value).collect()),
765        ),
766        (
767            Value::Str("completed".into()),
768            Value::Array(lists.completed.into_iter().map(summary_value).collect()),
769        ),
770        (
771            Value::Str("proposed".into()),
772            Value::Array(lists.proposed.into_iter().map(summary_value).collect()),
773        ),
774    ])
775}
776
777fn summary_value(summary: WorkSummary) -> Value {
778    Value::Map(vec![
779        (Value::Str("id".into()), Value::UInt(summary.id)),
780        (Value::Str("title".into()), Value::Str(summary.title)),
781        (Value::Str("created".into()), Value::UInt(summary.created)),
782        (Value::Str("edited".into()), Value::UInt(summary.edited)),
783        (Value::Str("author".into()), Value::Str(summary.author)),
784        (Value::Str("format".into()), Value::Str(summary.format)),
785        (Value::Str("comments".into()), Value::UInt(summary.comments)),
786    ])
787}
788
789fn document_value_response(document: WorkDocument) -> Value {
790    Value::Map(vec![
791        (Value::Str("id".into()), Value::UInt(document.id)),
792        (
793            Value::Str("scope".into()),
794            Value::Str(document.scope.as_str().into()),
795        ),
796        (Value::Str("content".into()), Value::Str(document.content)),
797        (
798            Value::Str("comments".into()),
799            Value::Array(document.comments.into_iter().map(comment_value).collect()),
800        ),
801        (
802            Value::Str("meta".into()),
803            Value::Map(vec![
804                (Value::Str("title".into()), Value::Str(document.title)),
805                (Value::Str("created".into()), Value::UInt(document.created)),
806                (Value::Str("edited".into()), Value::UInt(document.edited)),
807                (Value::Str("author".into()), Value::Str(document.author)),
808                (
809                    Value::Str("identity".into()),
810                    document.identity.map(Value::Bin).unwrap_or(Value::Nil),
811                ),
812                (
813                    Value::Str("signature".into()),
814                    document.signature.map(Value::Bin).unwrap_or(Value::Nil),
815                ),
816                (Value::Str("format".into()), Value::Str(document.format)),
817            ]),
818        ),
819    ])
820}
821
822fn comment_value(comment: WorkComment) -> Value {
823    Value::Map(vec![
824        (Value::Str("id".into()), Value::UInt(comment.id)),
825        (Value::Str("content".into()), Value::Str(comment.content)),
826        (Value::Str("created".into()), Value::UInt(comment.created)),
827        (Value::Str("edited".into()), Value::UInt(comment.edited)),
828        (Value::Str("author".into()), Value::Str(comment.author)),
829        (Value::Str("format".into()), Value::Str(comment.format)),
830    ])
831}
832
833fn stored_document_from_value(value: &Value) -> Result<StoredDocument> {
834    let content = value
835        .map_get("content")
836        .and_then(Value::as_str)
837        .unwrap_or("")
838        .to_string();
839    let meta = value
840        .map_get("meta")
841        .and_then(Value::as_map)
842        .ok_or_else(|| Error::msg("work document missing metadata"))?;
843    let title = map_get(meta, "title")
844        .and_then(Value::as_str)
845        .map(str::to_string);
846    let format = map_get(meta, "format")
847        .and_then(Value::as_str)
848        .map(normalize_format)
849        .unwrap_or_else(|| "markdown".to_string());
850    let created = map_get(meta, "created")
851        .and_then(Value::as_number)
852        .unwrap_or(0.0)
853        .max(0.0) as u64;
854    let edited = map_get(meta, "edited")
855        .and_then(Value::as_number)
856        .unwrap_or(0.0)
857        .max(0.0) as u64;
858    let author = map_get(meta, "author")
859        .and_then(value_to_author)
860        .unwrap_or([0; 16]);
861    let signature = map_get(meta, "signature").and_then(value_to_signature);
862    let identity = map_get(meta, "identity").and_then(value_to_identity);
863    Ok(StoredDocument {
864        content,
865        title,
866        format,
867        created,
868        edited,
869        author,
870        signature,
871        identity,
872    })
873}
874
875fn map_get<'a>(map: &'a [(Value, Value)], key: &str) -> Option<&'a Value> {
876    map.iter().find_map(|(map_key, value)| match map_key {
877        Value::Str(map_key) if map_key == key => Some(value),
878        _ => None,
879    })
880}
881
882fn map_get_repository(map: &[(Value, Value)]) -> Option<&str> {
883    map.iter().find_map(|(key, value)| match key {
884        Value::UInt(v) if *v == protocol::IDX_REPOSITORY => value.as_str(),
885        Value::Str(v) if v == "repository" => value.as_str(),
886        _ => None,
887    })
888}
889
890fn value_to_author(value: &Value) -> Option<[u8; 16]> {
891    match value {
892        Value::Bin(bytes) if bytes.len() == 16 => bytes.as_slice().try_into().ok(),
893        Value::Str(value) => parse_hex_16(value).ok(),
894        _ => None,
895    }
896}
897
898fn value_to_signature(value: &Value) -> Option<Vec<u8>> {
899    match value {
900        Value::Bin(bytes) => Some(bytes.clone()),
901        _ => None,
902    }
903}
904
905fn value_to_identity(value: &Value) -> Option<Vec<u8>> {
906    match value {
907        Value::Bin(bytes) if bytes.len() == 64 => Some(bytes.clone()),
908        _ => None,
909    }
910}
911
912fn normalize_format(value: &str) -> String {
913    match value {
914        "micron" => "micron".to_string(),
915        _ => "markdown".to_string(),
916    }
917}
918
919fn non_empty_trimmed(value: &str, message: &str) -> Result<String> {
920    let value = value.trim();
921    if value.is_empty() {
922        Err(Error::msg(message))
923    } else {
924        Ok(value.to_string())
925    }
926}
927
928fn validate_limit(size: usize) -> Result<()> {
929    if size > WORK_DOC_LIMIT {
930        Err(Error::msg("content limit exceeded"))
931    } else {
932        Ok(())
933    }
934}
935
936fn validate_signature(signature: Option<&[u8]>) -> Result<()> {
937    if signature.is_some_and(|signature| signature.len() != 64) {
938        Err(Error::msg("invalid signature"))
939    } else {
940        Ok(())
941    }
942}
943
944fn ensure_author(document: &StoredDocument, author: &[u8; 16]) -> Result<()> {
945    if &document.author == author {
946        Ok(())
947    } else {
948        Err(Error::msg("no access, not author"))
949    }
950}
951
952fn unix_time() -> u64 {
953    SystemTime::now()
954        .duration_since(UNIX_EPOCH)
955        .unwrap_or_default()
956        .as_secs()
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    const AUTHOR: [u8; 16] = [0x11; 16];
964    const OTHER: [u8; 16] = [0x22; 16];
965
966    #[test]
967    fn work_sidecar_path_appends_work_extension_to_repository_path() {
968        assert_eq!(
969            work_sidecar_path(Path::new("/tmp/repos/group/repo")),
970            PathBuf::from("/tmp/repos/group/repo.work")
971        );
972    }
973
974    #[test]
975    fn create_list_and_view_document_round_trip_msgpack_storage() {
976        let tmp = tempfile::tempdir().unwrap();
977        let work_path = tmp.path().join("repo.work");
978
979        let created = create_document(
980            &work_path,
981            WorkInput {
982                title: "  First task  ".into(),
983                content: "  Do the thing  ".into(),
984                format: "unknown".into(),
985                signature: None,
986                identity: None,
987                author: AUTHOR,
988            },
989        )
990        .unwrap();
991        assert_eq!(
992            created,
993            WorkCreated {
994                id: 1,
995                scope: WorkScope::Active
996            }
997        );
998        assert!(work_path.join("active/1/root").is_file());
999
1000        let raw = fs::read(work_path.join("active/1/root")).unwrap();
1001        let stored = msgpack::unpack_exact(&raw).unwrap();
1002        assert_eq!(
1003            stored.map_get("content").and_then(Value::as_str),
1004            Some("Do the thing")
1005        );
1006        assert_eq!(
1007            stored
1008                .map_get("meta")
1009                .and_then(Value::as_map)
1010                .and_then(|meta| map_get(meta, "format"))
1011                .and_then(Value::as_str),
1012            Some("markdown")
1013        );
1014
1015        let lists = list_documents(&work_path, WorkListScope::All).unwrap();
1016        assert_eq!(lists.active.len(), 1);
1017        assert_eq!(lists.completed.len(), 0);
1018        assert_eq!(lists.active[0].title, "First task");
1019        assert_eq!(lists.active[0].author, "11111111111111111111111111111111");
1020        assert_eq!(lists.active[0].comments, 0);
1021
1022        let document = view_document(&work_path, WorkScope::Active, 1)
1023            .unwrap()
1024            .unwrap();
1025        assert_eq!(document.title, "First task");
1026        assert_eq!(document.content, "Do the thing");
1027        assert_eq!(document.format, "markdown");
1028        assert_eq!(document.author_hash, AUTHOR);
1029        assert!(document.comments.is_empty());
1030    }
1031
1032    #[test]
1033    fn document_ids_are_allocated_across_active_and_completed_scopes() {
1034        let tmp = tempfile::tempdir().unwrap();
1035        let work_path = tmp.path().join("repo.work");
1036
1037        assert_eq!(create_sample(&work_path, "one").unwrap().id, 1);
1038        complete_document(&work_path, 1, &AUTHOR).unwrap();
1039        assert_eq!(create_sample(&work_path, "two").unwrap().id, 2);
1040
1041        let lists = list_documents(&work_path, WorkListScope::All).unwrap();
1042        assert_eq!(lists.active[0].id, 2);
1043        assert_eq!(lists.completed[0].id, 1);
1044    }
1045
1046    #[test]
1047    fn document_lists_sort_by_latest_created_or_edited_activity() {
1048        let tmp = tempfile::tempdir().unwrap();
1049        let work_path = tmp.path().join("repo.work");
1050
1051        assert_eq!(create_sample(&work_path, "older edited").unwrap().id, 1);
1052        assert_eq!(create_sample(&work_path, "newer untouched").unwrap().id, 2);
1053        let mut first = read_document(&root_path(&work_path, WorkScope::Active, 1)).unwrap();
1054        first.edited = first.created + 10_000;
1055        write_document(&root_path(&work_path, WorkScope::Active, 1), &first).unwrap();
1056
1057        let lists = list_documents(&work_path, WorkListScope::Active).unwrap();
1058        let ids: Vec<_> = lists.active.iter().map(|doc| doc.id).collect();
1059        assert_eq!(ids, vec![1, 2]);
1060    }
1061
1062    #[test]
1063    fn comments_are_numbered_counted_and_sorted() {
1064        let tmp = tempfile::tempdir().unwrap();
1065        let work_path = tmp.path().join("repo.work");
1066        create_sample(&work_path, "one").unwrap();
1067
1068        fs::write(
1069            work_path.join("active/1/9"),
1070            fs::read(work_path.join("active/1/root")).unwrap(),
1071        )
1072        .unwrap();
1073        let comment_id = add_comment(
1074            &work_path,
1075            WorkScope::Active,
1076            1,
1077            WorkCommentInput {
1078                content: "  update  ".into(),
1079                format: "micron".into(),
1080                signature: Some(vec![0xAA; 64]),
1081                author: OTHER,
1082            },
1083        )
1084        .unwrap();
1085        assert_eq!(comment_id, 10);
1086
1087        let lists = list_documents(&work_path, WorkListScope::Active).unwrap();
1088        assert_eq!(lists.active[0].comments, 2);
1089
1090        let document = view_document(&work_path, WorkScope::Active, 1)
1091            .unwrap()
1092            .unwrap();
1093        let ids: Vec<_> = document.comments.iter().map(|comment| comment.id).collect();
1094        assert_eq!(ids, vec![9, 10]);
1095        assert_eq!(document.comments[1].content, "update");
1096        assert_eq!(document.comments[1].format, "micron");
1097        assert_eq!(document.comments[1].signature, Some(vec![0xAA; 64]));
1098        assert_eq!(document.comments[1].author_hash, OTHER);
1099    }
1100
1101    #[test]
1102    fn activate_proposed_document_preserves_author_and_content() {
1103        let tmp = tempfile::tempdir().unwrap();
1104        let work_path = tmp.path().join("repo.work");
1105        create_sample(&work_path, "proposal").unwrap();
1106        std::fs::create_dir_all(work_path.join("proposed")).unwrap();
1107        std::fs::rename(work_path.join("active/1"), work_path.join("proposed/1")).unwrap();
1108
1109        assert!(activate_document(&work_path, 1, &OTHER).is_err());
1110        assert!(work_path.join("proposed/1/root").is_file());
1111        activate_document(&work_path, 1, &AUTHOR).unwrap();
1112        assert!(!work_path.join("proposed/1").exists());
1113        let document = view_document(&work_path, WorkScope::Active, 1)
1114            .unwrap()
1115            .unwrap();
1116        assert_eq!(document.title, "proposal");
1117        assert_eq!(document.author_hash, AUTHOR);
1118        assert!(activate_document(&work_path, 1, &AUTHOR).is_err());
1119    }
1120
1121    #[test]
1122    fn edit_delete_complete_and_activate_require_document_author() {
1123        let tmp = tempfile::tempdir().unwrap();
1124        let work_path = tmp.path().join("repo.work");
1125        create_sample(&work_path, "one").unwrap();
1126
1127        assert!(edit_document(
1128            &work_path,
1129            WorkScope::Active,
1130            1,
1131            &OTHER,
1132            WorkEdit {
1133                title: Some("nope".into()),
1134                content: None,
1135                signature: None,
1136                identity: None,
1137            },
1138        )
1139        .is_err());
1140        edit_document(
1141            &work_path,
1142            WorkScope::Active,
1143            1,
1144            &AUTHOR,
1145            WorkEdit {
1146                title: Some("  updated title ".into()),
1147                content: Some(" updated content ".into()),
1148                signature: Some(vec![0x55; 64]),
1149                identity: Some(vec![0x66; 64]),
1150            },
1151        )
1152        .unwrap();
1153        let document = view_document(&work_path, WorkScope::Active, 1)
1154            .unwrap()
1155            .unwrap();
1156        assert_eq!(document.title, "updated title");
1157        assert_eq!(document.content, "updated content");
1158        assert_eq!(document.signature, Some(vec![0x55; 64]));
1159        assert_eq!(document.identity, Some(vec![0x66; 64]));
1160
1161        assert!(complete_document(&work_path, 1, &OTHER).is_err());
1162        complete_document(&work_path, 1, &AUTHOR).unwrap();
1163        assert!(view_document(&work_path, WorkScope::Active, 1)
1164            .unwrap()
1165            .is_none());
1166        assert!(view_document(&work_path, WorkScope::Completed, 1)
1167            .unwrap()
1168            .is_some());
1169
1170        assert!(activate_document(&work_path, 1, &OTHER).is_err());
1171        activate_document(&work_path, 1, &AUTHOR).unwrap();
1172        assert!(view_document(&work_path, WorkScope::Completed, 1)
1173            .unwrap()
1174            .is_none());
1175        assert!(view_document(&work_path, WorkScope::Active, 1)
1176            .unwrap()
1177            .is_some());
1178
1179        assert!(delete_document(&work_path, WorkScope::Active, 1, &OTHER).is_err());
1180        delete_document(&work_path, WorkScope::Active, 1, &AUTHOR).unwrap();
1181        assert!(view_document(&work_path, WorkScope::Active, 1)
1182            .unwrap()
1183            .is_none());
1184    }
1185
1186    #[test]
1187    fn rejects_invalid_document_input() {
1188        let tmp = tempfile::tempdir().unwrap();
1189        let work_path = tmp.path().join("repo.work");
1190
1191        assert!(create_document(
1192            &work_path,
1193            WorkInput {
1194                title: " ".into(),
1195                content: "content".into(),
1196                format: "markdown".into(),
1197                signature: None,
1198                identity: None,
1199                author: AUTHOR,
1200            },
1201        )
1202        .is_err());
1203        assert!(create_document(
1204            &work_path,
1205            WorkInput {
1206                title: "title".into(),
1207                content: " ".into(),
1208                format: "markdown".into(),
1209                signature: None,
1210                identity: None,
1211                author: AUTHOR,
1212            },
1213        )
1214        .is_err());
1215        assert!(create_document(
1216            &work_path,
1217            WorkInput {
1218                title: "title".into(),
1219                content: "content".into(),
1220                format: "markdown".into(),
1221                signature: Some(vec![0; 63]),
1222                identity: None,
1223                author: AUTHOR,
1224            },
1225        )
1226        .is_err());
1227    }
1228
1229    #[test]
1230    fn document_content_limit_matches_upstream_256_kib() {
1231        assert_eq!(WORK_DOC_LIMIT, 256 * 1024);
1232
1233        let tmp = tempfile::tempdir().unwrap();
1234        let work_path = tmp.path().join("repo.work");
1235        let title = "title";
1236        let format = "markdown";
1237        let at_limit = "x".repeat(WORK_DOC_LIMIT - title.len() - format.len());
1238        let over_limit = "x".repeat(WORK_DOC_LIMIT - title.len() - format.len() + 1);
1239
1240        create_document(
1241            &work_path,
1242            WorkInput {
1243                title: title.into(),
1244                content: at_limit,
1245                format: format.into(),
1246                signature: None,
1247                identity: None,
1248                author: AUTHOR,
1249            },
1250        )
1251        .unwrap();
1252
1253        let err = create_document(
1254            &work_path,
1255            WorkInput {
1256                title: title.into(),
1257                content: over_limit,
1258                format: format.into(),
1259                signature: None,
1260                identity: None,
1261                author: AUTHOR,
1262            },
1263        )
1264        .unwrap_err();
1265        assert_eq!(err.to_string(), "content limit exceeded");
1266    }
1267
1268    fn create_sample(work_path: &Path, title: &str) -> Result<WorkCreated> {
1269        create_document(
1270            work_path,
1271            WorkInput {
1272                title: title.into(),
1273                content: "content".into(),
1274                format: "markdown".into(),
1275                signature: None,
1276                identity: None,
1277                author: AUTHOR,
1278            },
1279        )
1280    }
1281}