1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
extern crate custom_error;
extern crate home;
extern crate toml;
use custom_error::custom_error;
use std::{
fs::{create_dir_all, remove_file, rename, OpenOptions},
io::{prelude::*, BufReader, Write},
path::PathBuf,
};
use crate::workflows::{WorkflowError, Workflows};
use crate::{
get_terminal_width, KO_ACTIVE_TASKS_DIR, KO_ARCHIVED_TASKS_DIR, KO_BASE_DIR, KO_CFG_FILE,
};
pub type KanOrgBoardResult<T> = Result<T, KanOrgBoardError>;
custom_error! { pub KanOrgBoardError
FileError { source: std::io::Error } = @{
match source.kind() {
std::io::ErrorKind::NotFound => "File or program not found",
std::io::ErrorKind::PermissionDenied => "No permission to read/write the file",
_ => "Unexpected error reading/writing the file",
}
},
WorkflowError { source: WorkflowError } = "Workflow-related error ocurred: {source}",
ConfigFileNotFound { hints: String } = "Could not find configuration in any of the following \
paths: {hints}. Please, consider creating a new configuration with the `create` \
subcommand",
ConfigDeserializationError { file: String , error: String } = "Error deserializing the config \
file {file}: {error}",
TaskNotFound { task: u16 } = "Task `{task}` could not be found in any workflow",
TaskParsingError { task: String } = "Could not parse task `{task}` into u16",
}
/// Kanban organization workflows.
///
/// This utility will allow you create tasks, move them from one workspace to another, edit and
/// delete them.
pub struct KanOrgBoard {
/// Workflows found in the configuration file.
workflows: Workflows,
/// Task files directory path.
base_dir: PathBuf,
}
impl KanOrgBoard {
/// Searches for a config file and return it if succesfully found.
///
/// The base file name is `config` and is located inside the base directory `.kanorg.d/`. It
/// will be searched in all the `hints` directories provided.
///
/// # Arguments:
///
/// * `hints` - paths to search the config file in
///
/// # Errors:
///
/// If the config file was not found, an error will be raised.
fn search_config_file(hints: &[&PathBuf]) -> KanOrgBoardResult<PathBuf> {
let relative_cfg_file = format!("{}/{}", KO_BASE_DIR, KO_CFG_FILE);
for search_dir in hints {
let config_file = search_dir.join(&relative_cfg_file);
if config_file.is_file() {
return Ok(config_file);
}
}
Err(KanOrgBoardError::ConfigFileNotFound {
hints: format!("{:#?}", hints),
})
}
/// Saves the current [`KanOrgBoard`] workflows into the configuration file.
///
/// This will save all the workspaces as arrays of tasks with the TOML format.
fn save_config(&self) -> KanOrgBoardResult<()> {
let config_file = self.base_dir.join(KO_CFG_FILE);
let config_str = match toml::to_string(&self.workflows) {
Ok(config) => config,
Err(err) => {
return Err(KanOrgBoardError::ConfigDeserializationError {
file: config_file.to_str().unwrap().to_owned(),
error: err.to_string(),
})
}
};
OpenOptions::new()
.write(true)
.truncate(true)
.open(&config_file)?
.write_all(config_str.as_bytes())?;
Ok(())
}
/// Reads the first line of a file.
///
/// This method will serve as an utility for the [`KanOrgBoard::show()`] method, opening each
/// task to read the title (first line). Note that this function will strip the leading `# ` and
/// the trailing whitespaces from the line.
///
/// # Arguments:
///
/// * `file_path` - path to the file to read.
///
/// # Examples:
///
/// Having the file `/some/file`:
///
/// ```plain,no_run
/// # This is the title of the 1st task
///
/// this is a description
/// ```
///
/// We can run with the following:
///
/// ```plain,no_run
/// let file = PathBuf::from("/some/file");
/// assert_eq!(
/// KanOrgBoard::read_first_line_cleaned(file)?,
/// "This is the title of the 1st task".to_owned(),
/// );
/// ```
fn read_first_line_cleaned(file_path: PathBuf) -> KanOrgBoardResult<String> {
let mut first_line: String = String::new();
BufReader::new(OpenOptions::new().read(true).open(&file_path)?)
.read_line(&mut first_line)?;
Ok(first_line.strip_prefix("# ").unwrap().trim().to_owned())
}
/// Print in the screen a selected file.
///
/// # Arguments:
///
/// * `file_path` - path to the file to show.
///
/// * `writer` - the chosen buffer for writing.
///
/// # Errors:
///
/// * If the file can't be oppened.
///
/// * If the file can't be readed properly.
///
/// * If the an error ocurrs when writing the information.
fn print_file<W: Write>(file_path: PathBuf, writer: &mut W) -> KanOrgBoardResult<()> {
let mut contents: String = String::new();
BufReader::new(OpenOptions::new().read(true).open(&file_path)?)
.read_to_string(&mut contents)?;
writeln!(writer, "{}", contents)?;
Ok(())
}
/// Formats the task title given the task ID.
///
/// Making use of the [`KanOrgBoard::read_first_line_cleaned`] function, the `task`'s title will
/// be pretty printed along with the `task`'s ID.
///
/// # Arguments:
///
/// * `task` - ID of the task to format its title.
///
/// * `max_size` - maximun size of title at which the string is trimmed.
///
/// # Examples:
///
/// Having the file named `1` located under `$PWD/.kanorg.d/active.d/`:
///
/// ```plain,no_run
/// # This is the title of the 1st task
///
/// this is a description
/// ```
///
/// We can run with the following:
///
/// ```plain,no_run
/// let kanban = KanOrgBoard::new()?;
/// assert_eq!(
/// kanban.format_task_title(Some(1), 40 as usize)?,
/// "This is the title of the 1st task".to_owned()
/// );
/// assert_eq!(
/// kanban.format_task_title(Some(1), 10 as usize)?,
/// "This is th".to_owned()
/// );
/// assert_eq!(
/// kanban.format_task_title(Some(12312313), 40 as usize)?,
/// String::new(),
/// );
/// ```
fn format_task_title(&self, task: Option<&u16>, max_size: usize) -> KanOrgBoardResult<String> {
match task {
Some(task_id) => Ok(format!(
"{1:>4} {2:<.0$}",
max_size - 6,
task_id,
Self::read_first_line_cleaned(
self.base_dir
.join(KO_ACTIVE_TASKS_DIR)
.join(task_id.to_string())
)?
)),
None => Ok(String::new()),
}
}
/// Edits the given file path with the default editor.
///
/// # Arguments:
///
/// * `file_path` - path to the edited file.
///
/// # Errors:
///
/// If a problem occurs when editing the file or the process return code is not 0.
#[cfg(not(any(test, feature = "integration")))]
fn edit_file_interactivelly(file_path: PathBuf) -> KanOrgBoardResult<()> {
std::process::Command::new(std::env::var("EDITOR").unwrap_or("vi".to_owned()))
.arg(&file_path)
.status()?;
Ok(())
}
/// Creates a new instance of [`KanOrgBoard`] loading a configuration file.
///
/// The config file is searched in the current directory or the default user
/// folder. Checkout [`KanOrgBoard::search_config_file()`] to know more about
/// how it is searched. Once the config file is located, it is read and the
/// instance attributes populated. The module [`toml`] will be used to
/// deserialize the file contents. Check the [`Workflows`] struct to know
/// more about the config file format.
pub fn new(current_working_dir: &PathBuf) -> KanOrgBoardResult<Self> {
let config_file =
Self::search_config_file(&[current_working_dir, &home::home_dir().unwrap()])?;
let base_dir = config_file.parent().unwrap().to_path_buf();
create_dir_all(base_dir.join(KO_ACTIVE_TASKS_DIR))?;
create_dir_all(base_dir.join(KO_ARCHIVED_TASKS_DIR))?;
let mut contents: String = String::new();
OpenOptions::new()
.read(true)
.open(&config_file)?
.read_to_string(&mut contents)?;
let workflows: Workflows = Workflows::from_str(&contents)?;
Ok(Self {
workflows: workflows,
base_dir: base_dir,
})
}
/// Creates the base configuration in the chosen directory.
///
/// This function will create the following structure:
///
/// ```plain,no_run
/// .kanorg.d/
/// |-- active.d
/// |-- archive.d
/// `-- config
/// ```
///
/// Being:
///
/// * `.kanorg.d` - the base directory where the configuration and tasks are contained.
///
/// * `.kanorg.d/config` - configuration file. The file will have the contents:
///
/// ```plain,no_run
/// backlog = [<task1>, <task2>, ..., <taskn>]
/// todo = [<task1>, <task2>, ..., <taskn>]
/// doing = [<task1>, <task2>, ..., <taskn>]
/// done = [<task1>, <task2>, ..., <taskn>]
/// last_task = <last task ID>
/// ```
///
/// * `.kanorg.d/active.d` - directory in where the active task files (the ones found in
/// `backlog`, `todo`, `doing` or `done`) are located.
///
/// * `.kanorg.d/archive.d` - in this other directory, the popped task files from the `done`
/// workflow will be found.
///
/// # Arguments:
///
/// * `target_dir` - the target thir you want to create the base configuration. If no target
/// path is specified, the current directory will be used.
pub fn create(target_dir: &str) -> KanOrgBoardResult<()> {
let target_base_dir = PathBuf::from(target_dir).canonicalize()?.join(KO_BASE_DIR);
create_dir_all(target_base_dir.join(KO_ACTIVE_TASKS_DIR))?;
create_dir_all(target_base_dir.join(KO_ARCHIVED_TASKS_DIR))?;
let config_file = target_base_dir.join(KO_CFG_FILE);
if !config_file.is_file() {
OpenOptions::new()
.write(true)
.read(true)
.create(true)
.open(&config_file)?
.write_all(
"backlog = []\ntodo = []\ndoing = []\ndone = []\nlast_task = 0\n".as_bytes(),
)?;
}
Ok(())
}
/// Provides an organized view of the current tasks.
///
/// This method will print the three main workflows (todo, doing and done)
/// in a table style output. Also after finishing the main tasks, the
/// backlog will be printed alone with a maximun of 5 tasks.
///
/// This is an example output you can get:
///
/// ```plain,no_run
/// | TODO | DOING | DONE |
/// |-------------------------|-------------------------|-------------------------|
/// | 15 Task fifteen title | 13 Task thirteen title | 29 Esta es una mÃsera |
/// | 12 Task twelve title | 10 Task ten title | 11 Task eleven title |
/// | 14 Task fourteen title | | 9 Task nine title |
/// | | | 6 Task six title |
/// | | | 8 Task eight title |
///
/// BACKLOG
/// 26 este es el nuevo titulo
/// 25 Task twenty-five title
/// 24 Task twnety-four title
/// 23 Task twenty-three title
/// 22 Task twenty-two title
/// WARNING: The backlog has been trimmed. Run `ko show backlog` to see all the backlog tasks.
/// ```
///
/// The program will addapt to the terminal size and will print the columns
/// in concordance.
///
/// # Arguments:
///
/// * `task_or_workflow` - name of the workflow or the task to show. If this parameter is
/// provided, the output will be similar to one of the next outputs:
///
/// If it is a workflow and exists:
///
/// ```plain,no_run
/// TODO
/// 15 Task fifteen title
/// 12 Task twelve title
/// 14 Task fourteen title
/// ```
///
/// If the argument is parsed by [`u16`] and the task exists:
///
/// ```plain,no_run
/// # This is the task 12 sample title
///
/// some description from the task 12
///
/// ```
///
/// * `writer` - chosen buffer to where output the stdout.
///
/// # Example usage:
///
/// To show all the available workflows:
///
/// ```shell,no_run
/// ko show
/// ```
///
/// To show only one specific workflow:
///
/// ```shell,no_run
/// ko show todo
/// ```
pub fn show<W: Write>(
&self,
task_or_workflow: Option<&str>,
writer: &mut W,
) -> KanOrgBoardResult<()> {
let terminal_columns = get_terminal_width();
if let Some(target_object) = task_or_workflow {
match target_object.parse::<u16>() {
Ok(selected_task) => {
match self.workflows.find_workflow_name(&selected_task) {
Some(_) => Self::print_file(
self.base_dir
.join(format!("{}/{}", KO_ACTIVE_TASKS_DIR, selected_task)),
writer,
)?,
None => {
return Err(KanOrgBoardError::TaskNotFound {
task: selected_task,
})
}
};
}
// The argument is not parsed by [`u16`], so it must be a workflow.
Err(_) => {
let selected_workflow_name = target_object.to_lowercase();
let selected_workflow = self.workflows.workflow(&selected_workflow_name)?;
writeln!(
writer,
"KanOrg base dir: {}",
self.base_dir.to_str().unwrap()
)?;
writeln!(writer, "\n{}", selected_workflow_name.to_uppercase())?;
for selected_task in selected_workflow.iter() {
writeln!(
writer,
"{}",
self.format_task_title(Some(selected_task), terminal_columns)?
)?;
}
}
}
} else {
// We need one separator character per workflow plus the final one
let column_gap: usize = (terminal_columns - (3 + 1)) / 3;
let full_column_size: usize = column_gap * 3 + 2;
let line_divisor = format!("+{}+", str::repeat("-", full_column_size));
let backlog_workflow = self.workflows.workflow("backlog").unwrap();
let mut backlog_iter = backlog_workflow.iter();
let mut todo_iter = self.workflows.workflow("todo").unwrap().iter();
let mut doing_iter = self.workflows.workflow("doing").unwrap().iter();
let mut done_iter = self.workflows.workflow("done").unwrap().iter();
writeln!(writer, "{}", line_divisor)?;
writeln!(
writer,
"|{:^1$}|",
format!("KanOrg base dir: {}", self.base_dir.to_str().unwrap()),
full_column_size
)?;
writeln!(writer, "{}", line_divisor)?;
writeln!(
writer,
"|{:^3$}|{:^3$}|{:^3$}|",
"TODO", "DOING", "DONE", column_gap
)?;
writeln!(writer, "+{0}+{0}+{0}+", str::repeat("-", column_gap))?;
for _ in 0..self.workflows.main_max_len() {
writeln!(
writer,
"|{:3$}|{:3$}|{:3$}|",
self.format_task_title(todo_iter.next(), column_gap)?,
self.format_task_title(doing_iter.next(), column_gap)?,
self.format_task_title(done_iter.next(), column_gap)?,
column_gap,
)?;
}
writeln!(writer, "{}", line_divisor)?;
writeln!(writer, "\nBACKLOG")?;
let backlog_len = backlog_workflow.len();
let backlog_max_len = if backlog_len <= 5 { backlog_len } else { 5 };
for _ in 0..backlog_max_len {
writeln!(
writer,
"{}",
self.format_task_title(backlog_iter.next(), terminal_columns)?
)?;
}
if backlog_len > 5 {
writeln!(
writer,
"WARNING: The backlog has been trimmed. Run `ko show backlog` to see \
all the backlog tasks.",
)?;
}
}
Ok(())
}
/// Adds a new task to the choosen workflow.
///
/// This method will add a new task to the desired workflow. Moreover, a file will be created
/// with the title as the first line. After the creation, the default editor will be used to
/// edit the task description
///
/// Note that when adding a new task to the `done` workflow, if there are already 5 tasks, the
/// oldest one will be phisically moved to the `archive` and removed from the mentioned
/// workflow.
///
/// # Arguments:
///
/// * `title` - summary of the new task. It will be placed in the first line of the task file.
///
/// * `workflow_name` - initial column to put the new task. If no workflow is specified, it will
/// be placed in `backlog` (default workflow).
///
/// * `edit_task` - whether you want to edit the task after creating it.
///
/// # Errors:
///
/// * If the workflow does not exist.
///
/// * If the moving caused by the `done` workflow overflow have an error.
///
/// * If an error ocurrs when opening or writing the new task file.
///
/// * If an there is a problem saving the config changes.
///
/// # Example usage:
///
/// To add a new task into the backlog introducing:
///
/// ```shell,no_run
/// ko add "new task fancy title"
/// ```
///
/// To add a new task to the workflow `todo`:
///
/// ```shell,no_run
/// ko add "new task fancy title" todo
/// ```
pub fn add(
&mut self,
title: &str,
workflow_name: Option<&str>,
edit_task: bool,
) -> KanOrgBoardResult<()> {
let task_title: String = title.to_owned();
let active_tasks_dir = self.base_dir.join(KO_ACTIVE_TASKS_DIR);
let archive_tasks_dir = self.base_dir.join(KO_ARCHIVED_TASKS_DIR);
if let Some(bench_task) = self
.workflows
.add_new_task(&workflow_name.unwrap_or("backlog").to_lowercase())?
{
// There is an overflow in the `done` workflow. We will move the
// popped task to the `archive`
rename(
active_tasks_dir.join(bench_task.to_string()),
archive_tasks_dir.join(bench_task.to_string()),
)?;
}
let last_task_path = active_tasks_dir.join(self.workflows.last_task().to_string());
OpenOptions::new()
.create(true)
.write(true)
.open(&last_task_path)?
.write_all(format!("# {}\n", task_title).as_bytes())?;
if edit_task {
// Avoid calling the text editor during the tests
#[cfg(not(any(test, feature = "integration")))]
Self::edit_file_interactivelly(last_task_path)?;
}
self.save_config()?;
Ok(())
}
/// Moves a task from one it's workspace to a new one.
///
/// When moving a task, the task and workflow are checked. If no workflow are passed, `backlog`
/// would be used.
///
/// # Arguments:
///
/// * `task` - task string we want to move.
///
/// * `workflow_name` - the target workflow.
///
/// # Errors:
///
/// * If the `task` is not a number.
///
/// * If the task could not be found in any active workflow.
///
/// * If there was a problem moving the task.
///
/// * If there was a problem saving the settings.
pub fn relocate(&mut self, task: &str, workflow_name: Option<&str>) -> KanOrgBoardResult<()> {
let selected_task: u16 = match task.parse() {
Ok(value) => value,
Err(_) => {
return Err(KanOrgBoardError::TaskParsingError {
task: task.to_owned(),
})
}
};
let from_workflow = match self.workflows.find_workflow_name(&selected_task) {
Some(found_workflow_name) => found_workflow_name.to_owned(),
None => {
return Err(KanOrgBoardError::TaskNotFound {
task: selected_task,
})
}
};
let to_workflow = workflow_name.unwrap_or("backlog").to_lowercase();
if from_workflow == to_workflow {
println!(
"Task `{}` is already in the workflow `{}`.",
task, to_workflow
);
return Ok(());
}
let active_tasks_dir = self.base_dir.join(KO_ACTIVE_TASKS_DIR);
let archive_tasks_dir = self.base_dir.join(KO_ARCHIVED_TASKS_DIR);
if let Some(bench_task) =
self.workflows
.move_task(selected_task, &from_workflow, &to_workflow)?
{
// There is an overflow in the `done` workflow. We will move the
// popped task to the `archive`
rename(
active_tasks_dir.join(bench_task.to_string()),
archive_tasks_dir.join(bench_task.to_string()),
)?;
}
println!(
"Moved task `{}` from workflow `{}` to `{}`.",
task, from_workflow, to_workflow
);
self.save_config()?;
Ok(())
}
/// Edits a task form any active workflow.
///
/// # Arguments:
///
/// * `task` - task string we want to move.
///
/// # Errors:
///
/// * If the `task` is not a number.
///
/// * If the task could not be found in any active workflow.
///
/// * If there was a problem editing the task.
pub fn edit(&self, task: &str) -> KanOrgBoardResult<()> {
let selected_task: u16 = match task.parse() {
Ok(value) => value,
Err(_) => {
return Err(KanOrgBoardError::TaskParsingError {
task: task.to_owned(),
})
}
};
if let None = self.workflows.find_workflow_name(&selected_task) {
return Err(KanOrgBoardError::TaskNotFound {
task: selected_task,
});
}
// Avoid calling the text editor during the tests
#[cfg(not(any(test, feature = "integration")))]
Self::edit_file_interactivelly(
self.base_dir
.join(format!("{}/{}", KO_ACTIVE_TASKS_DIR, selected_task)),
)?;
Ok(())
}
/// Deletes a task from one active directory.
///
/// # Arguments:
///
/// * `task` - task string we want to move.
///
/// # Errors:
///
/// * If the `task` is not a number.
///
/// * If the task could not be found in any active workflow.
///
/// * If there was a problem moving the task.
///
/// * If there was a problem saving the settings.
pub fn delete(&mut self, task: &str) -> KanOrgBoardResult<()> {
let selected_task: u16 = match task.parse() {
Ok(value) => value,
Err(_) => {
return Err(KanOrgBoardError::TaskParsingError {
task: task.to_owned(),
})
}
};
let contained_in_workflow = match self.workflows.find_workflow_name(&selected_task) {
Some(found_workflow_name) => found_workflow_name.to_owned(),
None => {
return Err(KanOrgBoardError::TaskNotFound {
task: selected_task,
});
}
};
self.workflows
.remove_task(selected_task, &contained_in_workflow)?;
remove_file(
self.base_dir
.join(format!("{}/{}", KO_ACTIVE_TASKS_DIR, selected_task)),
)?;
println!(
"Deleted task `{}` from workflow `{}`.",
selected_task, contained_in_workflow
);
self.save_config()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
extern crate fs_extra;
extern crate tempfile;
use super::*;
fn setup_workspace(copy_example_config: bool) -> tempfile::TempDir {
let temp_dir =
tempfile::tempdir().expect("There was a problem creating test temporary file.");
if copy_example_config {
fs_extra::copy_items(
&[PathBuf::from(env!("PWD"))
.join("examples")
.join(KO_BASE_DIR)],
temp_dir.path(),
&fs_extra::dir::CopyOptions::new(),
)
.expect("There was a problem copying test files.");
}
temp_dir
}
#[test]
fn search_config_file() {
let tmpkeep = setup_workspace(true);
let temp_dir = tmpkeep.path();
let found_file = KanOrgBoard::search_config_file(&[&temp_dir.to_path_buf()])
.expect("Error, the config file could not be found.");
assert_eq!(
found_file,
temp_dir.join(format!("{}/{}", KO_BASE_DIR, KO_CFG_FILE))
);
}
#[test]
fn save_config() {
let tmpkeep = setup_workspace(false);
let temp_dir_base = tmpkeep.path().join(KO_BASE_DIR);
create_dir_all(&temp_dir_base).expect("There was an error creating the test setup dirs");
let temp_config_file = temp_dir_base.join(KO_CFG_FILE);
assert!(OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&temp_config_file)
.is_ok());
let base_workflow = "\
backlog = [5]\n\
todo = [4, 3]\n\
doing = [2]\n\
done = [1]\n\
last_task = 5\n";
let kanorg_saving = KanOrgBoard {
workflows: toml::from_str(&base_workflow).unwrap(),
base_dir: temp_dir_base,
}
.save_config();
assert!(kanorg_saving.is_ok());
let mut file_contents = String::new();
assert!(OpenOptions::new()
.read(true)
.open(&temp_config_file)
.unwrap()
.read_to_string(&mut file_contents)
.is_ok());
assert_eq!(file_contents, base_workflow);
}
#[test]
fn read_first_line_cleaned() {
let tmpkeep = setup_workspace(false);
let temp_file_path = tmpkeep.path().join("some_file");
assert!(OpenOptions::new()
.create(true)
.write(true)
.open(&temp_file_path)
.unwrap()
.write_all("# This is a test file\n\nthe description\n".as_bytes())
.is_ok());
let first_line = KanOrgBoard::read_first_line_cleaned(temp_file_path);
assert!(first_line.is_ok());
assert_eq!(first_line.unwrap(), "This is a test file".to_owned());
}
#[test]
fn print_file() {
let tmpkeep = setup_workspace(false);
let temp_file_path = tmpkeep.path().join("some_file");
let file_contents = "# This is a test file\n\nthe description\n";
assert!(OpenOptions::new()
.create(true)
.write(true)
.open(&temp_file_path)
.unwrap()
.write_all(file_contents.as_bytes())
.is_ok());
let mut stdout_capture = std::io::Cursor::new(Vec::new());
assert!(KanOrgBoard::print_file(temp_file_path.clone(), &mut stdout_capture).is_ok());
stdout_capture.seek(std::io::SeekFrom::Start(0)).unwrap();
assert_eq!(
String::from_utf8(stdout_capture.into_inner())
.expect("There was a problem decoding the output from the function")
// It seems the cursor always captures a new line feed at the end of the file.
.strip_suffix("\n")
.unwrap(),
file_contents
);
}
#[test]
fn format_task_title() {
let tmpkeep = setup_workspace(true);
let k = KanOrgBoard::new(&tmpkeep.path().to_path_buf());
assert!(k.is_ok());
let format_title = k.unwrap().format_task_title(Some(&8u16), 50 as usize);
assert!(format_title.is_ok());
assert_eq!(
format_title.unwrap(),
" 8 This is the task 8 sample title".to_owned(),
);
}
#[test]
fn create() {
let tmpkeep = setup_workspace(false);
let temp_dir = tmpkeep.path().to_path_buf();
let temp_base_dir = temp_dir.join(KO_BASE_DIR);
assert!(!&temp_base_dir.exists());
assert!(KanOrgBoard::create(&temp_dir.to_str().unwrap()).is_ok());
assert!(temp_base_dir.join(KO_CFG_FILE).is_file());
assert!(temp_base_dir.join(KO_ACTIVE_TASKS_DIR).is_dir());
assert!(temp_base_dir.join(KO_ARCHIVED_TASKS_DIR).is_dir());
}
#[test]
fn show() {
let tmpkeep = setup_workspace(true);
let temp_dir = tmpkeep.path().to_path_buf();
let k = KanOrgBoard::new(&temp_dir);
assert!(k.is_ok());
let expected_output = "\
+-----------------------------------------------------------------------------+\n\
| TODO | DOING | DONE |\n\
+-------------------------+-------------------------+-------------------------+\n\
| 18 This is the task 18 | 14 This is the task 14 | 12 This is the task 12 |\n\
| 17 This is the task 17 | 13 This is the task 13 | 11 This is the task 11 |\n\
| 16 This is the task 16 | | 10 This is the task 10 |\n\
| 15 This is the task 15 | | 9 This is the task 9 |\n\
| | | 8 This is the task 8 |\n\
+-----------------------------------------------------------------------------+\n\
\n\
BACKLOG\n \
25 This is the task 25 sample title\n \
24 This is the task 24 sample title\n \
23 This is the task 23 sample title\n \
22 This is the task 22 sample title\n \
21 This is the task 21 sample title\n\
WARNING: The backlog has been trimmed. Run `ko show backlog` to see all the backlog \
tasks.\n";
let mut stdout_capture = std::io::Cursor::new(Vec::new());
assert!(k.unwrap().show(None, &mut stdout_capture).is_ok());
stdout_capture.seek(std::io::SeekFrom::Start(0)).unwrap();
assert_eq!(
expected_output.as_bytes(),
// Removing the header because it will be different each run, due to the temp directory
&stdout_capture.into_inner()[160..]
);
}
#[test]
fn show_single() {
let tmpkeep = setup_workspace(true);
let temp_dir = tmpkeep.path().to_path_buf();
let k = KanOrgBoard::new(&temp_dir);
assert!(k.is_ok());
let expected_output = "\
DONE\n \
12 This is the task 12 sample title\n \
11 This is the task 11 sample title\n \
10 This is the task 10 sample title\n \
9 This is the task 9 sample title\n \
8 This is the task 8 sample title\n\
";
let mut stdout_capture = std::io::Cursor::new(Vec::new());
assert!(k.unwrap().show(Some("done"), &mut stdout_capture).is_ok());
let obtained_output = stdout_capture.into_inner();
// Find the \n position in order to match the correct output and leave out the header
let endline_pos = obtained_output.iter().position(|&e| e == 10u8).unwrap() as usize;
assert_eq!(
expected_output.as_bytes(),
// Remove the header with the config directory
&obtained_output[endline_pos+2..]
);
}
#[test]
fn add() {
let tmpkeep = setup_workspace(true);
let temp_dir = tmpkeep.path().to_path_buf();
let mut k = KanOrgBoard::new(&temp_dir)
.expect("Something unwanted ocurred creating KanOrgBoard object.");
assert!(k.add("some task title", Some("doing"), false).is_ok());
let last_task = k.workflows.last_task();
assert!(temp_dir
.join(format!(
"{}/{}/{}",
KO_BASE_DIR, KO_ACTIVE_TASKS_DIR, last_task,
))
.is_file());
}
#[test]
fn add_overflow() {
let tmpkeep = setup_workspace(true);
let temp_dir = tmpkeep.path().to_path_buf();
let mut k = KanOrgBoard::new(&temp_dir).expect("Error creating the kanboard.");
let oldest_done_task = k.workflows.workflow("done").unwrap()[4];
assert!(k.add("some task title", Some("done"), false).is_ok());
assert!(temp_dir
.join(format!(
"{}/{}/{}",
KO_BASE_DIR, KO_ARCHIVED_TASKS_DIR, oldest_done_task,
))
.is_file());
}
#[test]
fn relocate() {
let tmpkeep = setup_workspace(true);
let temp_dir = tmpkeep.path().to_path_buf();
let mut k = KanOrgBoard::new(&temp_dir).expect("Error creating the kanboard.");
assert!(k.workflows.workflow("todo").unwrap().contains(&17u16));
assert!(!k.workflows.workflow("doing").unwrap().contains(&17u16));
assert!(k.relocate("17", Some("doing")).is_ok());
assert!(!k.workflows.workflow("todo").unwrap().contains(&17u16));
assert!(k.workflows.workflow("doing").unwrap().contains(&17u16));
assert!(k.relocate("17", None).is_ok());
assert!(!k.workflows.workflow("doing").unwrap().contains(&17u16));
assert!(k.workflows.workflow("backlog").unwrap().contains(&17u16));
assert!(k.relocate("juanito", None).is_err());
assert!(k.relocate("12223", None).is_err())
}
#[test]
fn delete() {
let tmpkeep = setup_workspace(true);
let temp_dir = tmpkeep.path().to_path_buf();
let target_file =
temp_dir.join(format!("{}/{}/{}", KO_BASE_DIR, KO_ACTIVE_TASKS_DIR, "13"));
let mut k = KanOrgBoard::new(&temp_dir).expect("Error creating the kanboard.");
assert!(target_file.is_file());
assert!(k.delete("13").is_ok());
assert!(!target_file.is_file());
}
}