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