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<'a>(map: &'a [(Value, Value)]) -> Option<&'a 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}