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