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}