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}