1use std::fs;
2use std::io::{self, Write};
3use std::path::PathBuf;
4
5use rns_core::msgpack::{self, Value};
6
7use crate::client::{decode_status, SyncClient};
8use crate::config::ClientConfig;
9use crate::logging;
10use crate::protocol;
11use crate::util::{default_reticulum_dir, default_rngit_dir, parse_rns_url};
12use crate::{Error, Result};
13
14pub fn main<I>(args: I) -> Result<()>
15where
16 I: IntoIterator<Item = String>,
17{
18 let options = WorkOptions::parse(args)?;
19 let (dest_hash, repository) = parse_rns_url(&options.remote)?;
20 let rngit_dir = options.config_dir.unwrap_or_else(default_rngit_dir);
21 let rns_dir = options.rns_config_dir.or_else(default_reticulum_dir);
22 let (config, created) = ClientConfig::load_or_create(rngit_dir, rns_dir)?;
23 logging::init_file_logger(&config.dir.join("client_log"), config.log_level)?;
24 if created {
25 return Err(Error::msg(format!(
26 "created default config at {}; edit it and run again",
27 config.dir.join("client_config").display()
28 )));
29 }
30
31 let client = SyncClient::connect(config, dest_hash)?;
32 let mut transport = NetWorkTransport { client, repository };
33 run_work_command(&mut transport, &options.command, io::stdout())
34}
35
36trait WorkTransport {
37 fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>>;
38}
39
40struct NetWorkTransport {
41 client: SyncClient,
42 repository: String,
43}
44
45impl WorkTransport for NetWorkTransport {
46 fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
47 let response = self.client.request(
48 protocol::PATH_WORK,
49 request_with_repository(data, &self.repository)?,
50 )?;
51 let bytes = protocol::response_bin(&response.data)?;
52 decode_status(bytes)
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57struct WorkOptions {
58 config_dir: Option<PathBuf>,
59 rns_config_dir: Option<PathBuf>,
60 remote: String,
61 command: WorkCommand,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65enum WorkCommand {
66 List {
67 scope: String,
68 },
69 View {
70 scope: String,
71 id: u64,
72 },
73 Create {
74 title: String,
75 content_path: PathBuf,
76 },
77 Edit {
78 scope: String,
79 id: u64,
80 title: Option<String>,
81 content_path: Option<PathBuf>,
82 },
83 Delete {
84 scope: String,
85 id: u64,
86 yes: bool,
87 },
88 Comment {
89 scope: String,
90 id: u64,
91 content_path: PathBuf,
92 },
93 Perms {
94 id: u64,
95 content_path: Option<PathBuf>,
96 },
97 Complete {
98 id: u64,
99 },
100 Activate {
101 id: u64,
102 },
103}
104
105impl WorkOptions {
106 fn parse<I>(args: I) -> Result<Self>
107 where
108 I: IntoIterator<Item = String>,
109 {
110 let mut config_dir = None;
111 let mut rns_config_dir = None;
112 let mut scope = "active".to_string();
113 let mut id = None;
114 let mut title = None;
115 let mut content_path = None;
116 let mut yes = false;
117 let mut positional = Vec::new();
118 let mut args = args.into_iter();
119 while let Some(arg) = args.next() {
120 match arg.as_str() {
121 "-c" | "--config" => {
122 config_dir = Some(PathBuf::from(
123 args.next()
124 .ok_or_else(|| Error::msg("missing config path"))?,
125 ));
126 }
127 "--rnsconfig" => {
128 rns_config_dir = Some(PathBuf::from(
129 args.next()
130 .ok_or_else(|| Error::msg("missing RNS config path"))?,
131 ));
132 }
133 "--scope" => {
134 scope = args.next().ok_or_else(|| Error::msg("missing scope"))?;
135 }
136 "-d" | "--id" => {
137 id = Some(parse_id(
138 &args
139 .next()
140 .ok_or_else(|| Error::msg("missing document ID"))?,
141 )?);
142 }
143 "-t" | "--title" => {
144 title = Some(args.next().ok_or_else(|| Error::msg("missing title"))?);
145 }
146 "--content" => {
147 content_path = Some(PathBuf::from(
148 args.next()
149 .ok_or_else(|| Error::msg("missing content path"))?,
150 ));
151 }
152 "-y" | "--yes" => yes = true,
153 "-h" | "--help" => return Err(Error::msg(usage())),
154 other => positional.push(other.to_string()),
155 }
156 }
157 if positional.len() < 2 {
158 return Err(Error::msg(usage()));
159 }
160 let remote = positional[0].clone();
161 let command = match positional[1].as_str() {
162 "list" => WorkCommand::List { scope },
163 "view" => WorkCommand::View {
164 scope,
165 id: id
166 .or_else(|| positional.get(2).and_then(|value| parse_id(value).ok()))
167 .ok_or_else(|| Error::msg("work view requires --id"))?,
168 },
169 "create" => WorkCommand::Create {
170 title: title.ok_or_else(|| Error::msg("work create requires --title"))?,
171 content_path: content_path
172 .ok_or_else(|| Error::msg("work create requires --content"))?,
173 },
174 "edit" => WorkCommand::Edit {
175 scope,
176 id: id.ok_or_else(|| Error::msg("work edit requires --id"))?,
177 title,
178 content_path,
179 },
180 "delete" => WorkCommand::Delete {
181 scope,
182 id: id.ok_or_else(|| Error::msg("work delete requires --id"))?,
183 yes,
184 },
185 "comment" | "update" => WorkCommand::Comment {
186 scope,
187 id: id.ok_or_else(|| Error::msg("work comment requires --id"))?,
188 content_path: content_path
189 .ok_or_else(|| Error::msg("work comment requires --content"))?,
190 },
191 "perms" => WorkCommand::Perms {
192 id: id.ok_or_else(|| Error::msg("work perms requires --id"))?,
193 content_path,
194 },
195 "complete" => WorkCommand::Complete {
196 id: id.ok_or_else(|| Error::msg("work complete requires --id"))?,
197 },
198 "activate" => WorkCommand::Activate {
199 id: id.ok_or_else(|| Error::msg("work activate requires --id"))?,
200 },
201 other => return Err(Error::msg(format!("unknown work operation {other}"))),
202 };
203 Ok(Self {
204 config_dir,
205 rns_config_dir,
206 remote,
207 command,
208 })
209 }
210}
211
212fn run_work_command(
213 transport: &mut impl WorkTransport,
214 command: &WorkCommand,
215 mut output: impl Write,
216) -> Result<()> {
217 match command {
218 WorkCommand::List { scope } => {
219 let body =
220 transport.request(request("list", &[("scope", Value::Str(scope.clone()))]))?;
221 let value = msgpack::unpack_exact(&body)
222 .map_err(|e| Error::msg(format!("invalid work list: {e}")))?;
223 print_work_list(&value, &mut output)
224 }
225 WorkCommand::View { scope, id } => {
226 let body = transport.request(request(
227 "view",
228 &[
229 ("scope", Value::Str(scope.clone())),
230 ("doc_id", Value::UInt(*id)),
231 ],
232 ))?;
233 let value = msgpack::unpack_exact(&body)
234 .map_err(|e| Error::msg(format!("invalid work document: {e}")))?;
235 print_work_document(&value, &mut output)
236 }
237 WorkCommand::Create {
238 title,
239 content_path,
240 } => {
241 let content = fs::read_to_string(content_path)?;
242 let body = transport.request(request(
243 "create",
244 &[
245 ("title", Value::Str(title.clone())),
246 ("content", Value::Str(content)),
247 ("format", Value::Str(format_for_path(content_path))),
248 ],
249 ))?;
250 let value = msgpack::unpack_exact(&body)
251 .map_err(|e| Error::msg(format!("invalid create response: {e}")))?;
252 writeln!(
253 output,
254 "Created work document {} #{}",
255 value
256 .map_get("scope")
257 .and_then(Value::as_str)
258 .unwrap_or("active"),
259 value.map_get("id").and_then(Value::as_integer).unwrap_or(0)
260 )?;
261 Ok(())
262 }
263 WorkCommand::Edit {
264 scope,
265 id,
266 title,
267 content_path,
268 } => {
269 let mut fields = vec![
270 ("scope", Value::Str(scope.clone())),
271 ("doc_id", Value::UInt(*id)),
272 ];
273 if let Some(title) = title {
274 fields.push(("title", Value::Str(title.clone())));
275 }
276 if let Some(path) = content_path {
277 fields.push(("content", Value::Str(fs::read_to_string(path)?)));
278 }
279 transport.request(request("edit", &fields))?;
280 writeln!(output, "Updated work document {scope} #{id}")?;
281 Ok(())
282 }
283 WorkCommand::Delete { scope, id, yes } => {
284 if !yes {
285 return Err(Error::msg("work delete requires --yes"));
286 }
287 transport.request(request(
288 "delete",
289 &[
290 ("scope", Value::Str(scope.clone())),
291 ("doc_id", Value::UInt(*id)),
292 ],
293 ))?;
294 writeln!(output, "Deleted work document {scope} #{id}")?;
295 Ok(())
296 }
297 WorkCommand::Comment {
298 scope,
299 id,
300 content_path,
301 } => {
302 let body = transport.request(request(
303 "comment",
304 &[
305 ("scope", Value::Str(scope.clone())),
306 ("doc_id", Value::UInt(*id)),
307 ("content", Value::Str(fs::read_to_string(content_path)?)),
308 ("format", Value::Str(format_for_path(content_path))),
309 ],
310 ))?;
311 let value = msgpack::unpack_exact(&body)
312 .map_err(|e| Error::msg(format!("invalid comment response: {e}")))?;
313 writeln!(
314 output,
315 "Added update #{} to {scope} document #{id}",
316 value.map_get("id").and_then(Value::as_integer).unwrap_or(0)
317 )?;
318 Ok(())
319 }
320 WorkCommand::Perms { id, content_path } => {
321 if let Some(path) = content_path {
322 transport.request(request(
323 "perms",
324 &[
325 ("doc_id", Value::UInt(*id)),
326 ("step", Value::Str("set".into())),
327 ("content", Value::Str(fs::read_to_string(path)?)),
328 ],
329 ))?;
330 writeln!(output, "Updated permissions for work document #{id}")?;
331 } else {
332 let body = transport.request(request(
333 "perms",
334 &[
335 ("doc_id", Value::UInt(*id)),
336 ("step", Value::Str("get".into())),
337 ],
338 ))?;
339 let value = msgpack::unpack_exact(&body)
340 .map_err(|e| Error::msg(format!("invalid permissions response: {e}")))?;
341 write!(
342 output,
343 "{}",
344 value
345 .map_get("content")
346 .and_then(Value::as_str)
347 .unwrap_or("")
348 )?;
349 }
350 Ok(())
351 }
352 WorkCommand::Complete { id } => {
353 transport.request(request("complete", &[("doc_id", Value::UInt(*id))]))?;
354 writeln!(output, "Completed work document #{id}")?;
355 Ok(())
356 }
357 WorkCommand::Activate { id } => {
358 transport.request(request("activate", &[("doc_id", Value::UInt(*id))]))?;
359 writeln!(output, "Activated work document #{id}")?;
360 Ok(())
361 }
362 }
363}
364
365fn request(operation: &str, fields: &[(&str, Value)]) -> Vec<u8> {
366 let mut map = vec![
367 (
368 Value::UInt(protocol::IDX_REPOSITORY),
369 Value::Str("__repository__".into()),
370 ),
371 (
372 Value::Str("operation".into()),
373 Value::Str(operation.to_string()),
374 ),
375 ];
376 map.extend(
377 fields
378 .iter()
379 .map(|(key, value)| (Value::Str((*key).into()), value.clone())),
380 );
381 msgpack::pack(&Value::Map(map))
382}
383
384fn request_with_repository(data: Vec<u8>, repository: &str) -> Result<Vec<u8>> {
385 let mut value =
386 msgpack::unpack_exact(&data).map_err(|e| Error::msg(format!("invalid request: {e}")))?;
387 if let Value::Map(entries) = &mut value {
388 for (key, val) in entries {
389 if matches!(key, Value::UInt(v) if *v == protocol::IDX_REPOSITORY) {
390 *val = Value::Str(repository.to_string());
391 return Ok(msgpack::pack(&value));
392 }
393 }
394 }
395 Err(Error::msg("work request missing repository field"))
396}
397
398fn print_work_list(value: &Value, mut output: impl Write) -> Result<()> {
399 let active = value
400 .map_get("active")
401 .and_then(Value::as_array)
402 .unwrap_or(&[]);
403 let completed = value
404 .map_get("completed")
405 .and_then(Value::as_array)
406 .unwrap_or(&[]);
407 if active.is_empty() && completed.is_empty() {
408 writeln!(output, "No work documents")?;
409 return Ok(());
410 }
411 print_work_section("Active documents", active, &mut output)?;
412 print_work_section("Completed documents", completed, &mut output)?;
413 Ok(())
414}
415
416fn print_work_section(title: &str, docs: &[Value], mut output: impl Write) -> Result<()> {
417 if docs.is_empty() {
418 return Ok(());
419 }
420 writeln!(output, "{title}")?;
421 for doc in docs {
422 writeln!(
423 output,
424 "#{} {} ({} update(s))",
425 doc.map_get("id").and_then(Value::as_integer).unwrap_or(0),
426 doc.map_get("title")
427 .and_then(Value::as_str)
428 .unwrap_or("Untitled"),
429 doc.map_get("comments")
430 .and_then(Value::as_integer)
431 .unwrap_or(0)
432 )?;
433 }
434 Ok(())
435}
436
437fn print_work_document(value: &Value, mut output: impl Write) -> Result<()> {
438 let title = value
439 .map_get("meta")
440 .and_then(|meta| meta.map_get("title"))
441 .and_then(Value::as_str)
442 .unwrap_or("Untitled");
443 writeln!(
444 output,
445 "{} (#{} {})",
446 title,
447 value.map_get("id").and_then(Value::as_integer).unwrap_or(0),
448 value
449 .map_get("scope")
450 .and_then(Value::as_str)
451 .unwrap_or("active")
452 )?;
453 writeln!(
454 output,
455 "{}",
456 value
457 .map_get("content")
458 .and_then(Value::as_str)
459 .unwrap_or("")
460 )?;
461 let comments = value
462 .map_get("comments")
463 .and_then(Value::as_array)
464 .unwrap_or(&[]);
465 for comment in comments {
466 writeln!(
467 output,
468 "\nUpdate #{}\n{}",
469 comment
470 .map_get("id")
471 .and_then(Value::as_integer)
472 .unwrap_or(0),
473 comment
474 .map_get("content")
475 .and_then(Value::as_str)
476 .unwrap_or("")
477 )?;
478 }
479 Ok(())
480}
481
482fn parse_id(value: &str) -> Result<u64> {
483 value.parse().map_err(|_| Error::msg("invalid document ID"))
484}
485
486fn format_for_path(path: &std::path::Path) -> String {
487 if path.extension().and_then(|ext| ext.to_str()) == Some("mu") {
488 "micron".into()
489 } else {
490 "markdown".into()
491 }
492}
493
494fn usage() -> &'static str {
495 "usage: rngit work [--config DIR] [--rnsconfig DIR] [--scope active|completed|all] [--id N] [--title TITLE] [--content PATH] [-y|--yes] <rns://destination/repo> <list|view|create|edit|delete|comment|perms|complete|activate>"
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501
502 #[derive(Default)]
503 struct FakeTransport {
504 requests: Vec<Value>,
505 responses: Vec<Vec<u8>>,
506 }
507
508 impl WorkTransport for FakeTransport {
509 fn request(&mut self, data: Vec<u8>) -> Result<Vec<u8>> {
510 self.requests.push(msgpack::unpack_exact(&data).unwrap());
511 Ok(self.responses.remove(0))
512 }
513 }
514
515 #[test]
516 fn parses_work_commands() {
517 assert_eq!(
518 WorkOptions::parse(args(&[
519 "rns://00112233445566778899aabbccddeeff/group/repo",
520 "list",
521 "--scope",
522 "all",
523 ]))
524 .unwrap()
525 .command,
526 WorkCommand::List {
527 scope: "all".into()
528 }
529 );
530 assert_eq!(
531 WorkOptions::parse(args(&[
532 "rns://00112233445566778899aabbccddeeff/group/repo",
533 "view",
534 "--id",
535 "7",
536 ]))
537 .unwrap()
538 .command,
539 WorkCommand::View {
540 scope: "active".into(),
541 id: 7,
542 }
543 );
544 assert!(WorkOptions::parse(args(&[
545 "rns://00112233445566778899aabbccddeeff/group/repo",
546 "create",
547 "--title",
548 "Task",
549 ]))
550 .is_err());
551 assert_eq!(
552 WorkOptions::parse(args(&[
553 "rns://00112233445566778899aabbccddeeff/group/repo",
554 "perms",
555 "--id",
556 "7",
557 ]))
558 .unwrap()
559 .command,
560 WorkCommand::Perms {
561 id: 7,
562 content_path: None,
563 }
564 );
565 }
566
567 #[test]
568 fn create_sends_work_create_request() {
569 let tmp = tempfile::tempdir().unwrap();
570 let content = tmp.path().join("WORK.md");
571 fs::write(&content, "# Body\n").unwrap();
572 let mut transport = FakeTransport {
573 requests: Vec::new(),
574 responses: vec![msgpack::pack(&Value::Map(vec![
575 (Value::Str("id".into()), Value::UInt(3)),
576 (Value::Str("scope".into()), Value::Str("active".into())),
577 ]))],
578 };
579 let mut output = Vec::new();
580 run_work_command(
581 &mut transport,
582 &WorkCommand::Create {
583 title: "Task".into(),
584 content_path: content,
585 },
586 &mut output,
587 )
588 .unwrap();
589 assert!(String::from_utf8(output).unwrap().contains("active #3"));
590 assert_eq!(
591 transport.requests[0]
592 .map_get("operation")
593 .and_then(Value::as_str),
594 Some("create")
595 );
596 assert_eq!(
597 transport.requests[0]
598 .map_get("content")
599 .and_then(Value::as_str),
600 Some("# Body\n")
601 );
602 }
603
604 #[test]
605 fn list_and_view_format_responses() {
606 let mut transport = FakeTransport {
607 requests: Vec::new(),
608 responses: vec![
609 msgpack::pack(&Value::Map(vec![(
610 Value::Str("active".into()),
611 Value::Array(vec![Value::Map(vec![
612 (Value::Str("id".into()), Value::UInt(1)),
613 (Value::Str("title".into()), Value::Str("Task".into())),
614 (Value::Str("comments".into()), Value::UInt(2)),
615 ])]),
616 )])),
617 msgpack::pack(&Value::Map(vec![
618 (Value::Str("id".into()), Value::UInt(1)),
619 (Value::Str("scope".into()), Value::Str("active".into())),
620 (Value::Str("content".into()), Value::Str("Body".into())),
621 (
622 Value::Str("meta".into()),
623 Value::Map(vec![(
624 Value::Str("title".into()),
625 Value::Str("Task".into()),
626 )]),
627 ),
628 (
629 Value::Str("comments".into()),
630 Value::Array(vec![Value::Map(vec![
631 (Value::Str("id".into()), Value::UInt(1)),
632 (Value::Str("content".into()), Value::Str("Update".into())),
633 ])]),
634 ),
635 ])),
636 ],
637 };
638 let mut output = Vec::new();
639 run_work_command(
640 &mut transport,
641 &WorkCommand::List {
642 scope: "active".into(),
643 },
644 &mut output,
645 )
646 .unwrap();
647 run_work_command(
648 &mut transport,
649 &WorkCommand::View {
650 scope: "active".into(),
651 id: 1,
652 },
653 &mut output,
654 )
655 .unwrap();
656 let output = String::from_utf8(output).unwrap();
657 assert!(output.contains("#1 Task (2 update(s))"));
658 assert!(output.contains("Task (#1 active)"));
659 assert!(output.contains("Update #1"));
660 }
661
662 #[test]
663 fn delete_requires_yes_and_lifecycle_commands_send_requests() {
664 let tmp = tempfile::tempdir().unwrap();
665 let comment_path = tmp.path().join("COMMENT.md");
666 fs::write(&comment_path, "Update").unwrap();
667 let mut transport = FakeTransport {
668 requests: Vec::new(),
669 responses: vec![
670 msgpack::pack(&Value::Map(vec![(Value::Str("id".into()), Value::UInt(1))])),
671 Vec::new(),
672 Vec::new(),
673 Vec::new(),
674 ],
675 };
676 assert!(run_work_command(
677 &mut transport,
678 &WorkCommand::Delete {
679 scope: "active".into(),
680 id: 1,
681 yes: false,
682 },
683 Vec::new(),
684 )
685 .is_err());
686 run_work_command(
687 &mut transport,
688 &WorkCommand::Comment {
689 scope: "active".into(),
690 id: 1,
691 content_path: comment_path,
692 },
693 Vec::new(),
694 )
695 .unwrap();
696 run_work_command(&mut transport, &WorkCommand::Complete { id: 1 }, Vec::new()).unwrap();
697 run_work_command(&mut transport, &WorkCommand::Activate { id: 1 }, Vec::new()).unwrap();
698 run_work_command(
699 &mut transport,
700 &WorkCommand::Delete {
701 scope: "active".into(),
702 id: 1,
703 yes: true,
704 },
705 Vec::new(),
706 )
707 .unwrap();
708 let ops: Vec<_> = transport
709 .requests
710 .iter()
711 .filter_map(|request| request.map_get("operation").and_then(Value::as_str))
712 .collect();
713 assert_eq!(ops, vec!["comment", "complete", "activate", "delete"]);
714 }
715
716 #[test]
717 fn perms_get_and_set_send_work_permission_requests() {
718 let tmp = tempfile::tempdir().unwrap();
719 let permissions = tmp.path().join("allowed.txt");
720 fs::write(&permissions, "interact = all\n").unwrap();
721 let mut transport = FakeTransport {
722 requests: Vec::new(),
723 responses: vec![
724 msgpack::pack(&Value::Map(vec![(
725 Value::Str("content".into()),
726 Value::Str("admin = all\n".into()),
727 )])),
728 Vec::new(),
729 ],
730 };
731 let mut output = Vec::new();
732 run_work_command(
733 &mut transport,
734 &WorkCommand::Perms {
735 id: 3,
736 content_path: None,
737 },
738 &mut output,
739 )
740 .unwrap();
741 run_work_command(
742 &mut transport,
743 &WorkCommand::Perms {
744 id: 3,
745 content_path: Some(permissions),
746 },
747 &mut output,
748 )
749 .unwrap();
750 assert!(String::from_utf8(output).unwrap().contains("admin = all"));
751 assert_eq!(
752 transport.requests[0]
753 .map_get("operation")
754 .and_then(Value::as_str),
755 Some("perms")
756 );
757 assert_eq!(
758 transport.requests[0]
759 .map_get("step")
760 .and_then(Value::as_str),
761 Some("get")
762 );
763 assert_eq!(
764 transport.requests[1]
765 .map_get("step")
766 .and_then(Value::as_str),
767 Some("set")
768 );
769 assert_eq!(
770 transport.requests[1]
771 .map_get("content")
772 .and_then(Value::as_str),
773 Some("interact = all\n")
774 );
775 }
776
777 fn args(values: &[&str]) -> Vec<String> {
778 values.iter().map(|value| value.to_string()).collect()
779 }
780}