Skip to main content

endbasic_std/storage/
cmds.rs

1// EndBASIC
2// Copyright 2021 Julio Merino
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17//! File system interaction.
18
19use super::time_format_error_to_io_error;
20use crate::MachineBuilder;
21use crate::console::{Console, Pager, is_narrow};
22use crate::storage::Storage;
23use async_trait::async_trait;
24use endbasic_core::{
25    ArgSep, ArgSepSyntax, CallResult, Callable, CallableMetadata, CallableMetadataBuilder,
26    ExprType, RequiredValueSyntax, Scope, SingularArgSyntax,
27};
28use std::borrow::Cow;
29use std::cell::RefCell;
30use std::cmp;
31use std::io;
32use std::rc::Rc;
33use std::str;
34use time::format_description;
35
36/// Category description for all symbols provided by this module.
37const CATEGORY: &str = "File system
38The EndBASIC storage subsystem is organized as a collection of drives, each identified by a \
39case-insensitive name.  Drives can be backed by a multitude of file systems with different \
40behaviors, and their targets are specified as URIs.  Special targets include: memory://, which \
41points to an in-memory read/write drive; and demos://, which points to a read-only drive with \
42sample programs.  Other targets may be available such as file:// to access a local directory or \
43local:// to access web-local storage, depending on the context.  The output of the MOUNT command \
44can help to identify which targets are available.
45All commands that operate with files take a path.  Paths in EndBASIC can be of the form \
46FILENAME.EXT, in which case they refer to a file in the current drive; or DRIVE:/FILENAME.EXT and \
47DRIVE:FILENAME.EXT, in which case they refer to a file in the specified drive.  Note that the \
48slash before the file name is currently optional because EndBASIC does not support directories \
49yet.  Furthermore, if .EXT is missing, a .BAS extension is assumed.
50Be aware that the commands below must be invoked using proper EndBASIC syntax.  In particular, \
51this means that path arguments must be double-quoted and multiple arguments have to be separated \
52by a comma (not a space).  If you have used commands like CD, DIR, or MOUNT in other contexts, \
53this is likely to confuse you.
54See the \"Stored program\" help topic for information on how to load, modify, and save programs.";
55
56/// Shows the contents of the given storage location.
57async fn show_dir(storage: &Storage, console: &mut dyn Console, path: &str) -> io::Result<()> {
58    let canonical_path = storage.make_canonical(path)?;
59    let files = storage.enumerate(path).await?;
60
61    let format = format_description::parse("[year]-[month]-[day] [hour]:[minute]")
62        .expect("Hardcoded format must be valid");
63    let show_narrow = is_narrow(&*console);
64
65    let mut pager = Pager::new(console)?;
66    pager.print("").await?;
67    pager.print(&format!("    Directory of {}", canonical_path)).await?;
68    pager.print("").await?;
69    if show_narrow {
70        let mut total_files = 0;
71        for name in files.dirents().keys() {
72            pager.print(&format!("    {}", name,)).await?;
73            total_files += 1;
74        }
75        if total_files > 0 {
76            pager.print("").await?;
77        }
78        pager.print(&format!("    {} file(s)", total_files)).await?;
79    } else {
80        let mut total_files = 0;
81        let mut total_bytes = 0;
82        pager.print("    Modified              Size    Name").await?;
83        for (name, details) in files.dirents() {
84            pager
85                .print(&format!(
86                    "    {}    {:6}    {}",
87                    details.date.format(&format).map_err(time_format_error_to_io_error)?,
88                    details.length,
89                    name,
90                ))
91                .await?;
92            total_files += 1;
93            total_bytes += details.length;
94        }
95        if total_files > 0 {
96            pager.print("").await?;
97        }
98        pager.print(&format!("    {} file(s), {} bytes", total_files, total_bytes)).await?;
99        if let (Some(disk_quota), Some(disk_free)) = (files.disk_quota(), files.disk_free()) {
100            pager
101                .print(&format!("    {} of {} bytes free", disk_free.bytes, disk_quota.bytes))
102                .await?;
103        }
104    }
105    pager.print("").await?;
106    Ok(())
107}
108
109/// Shows the mounted drives.
110fn show_drives(storage: &Storage, console: &mut dyn Console) -> io::Result<()> {
111    let drive_info = storage.mounted();
112    let max_length = drive_info.keys().fold("Name".len(), |max, name| cmp::max(max, name.len()));
113
114    console.print("")?;
115    let filler = " ".repeat(max_length - "Name".len());
116    console.print(&format!("    Name{}    Target", filler))?;
117    let num_drives = drive_info.len();
118    for (name, uri) in drive_info {
119        let filler = " ".repeat(max_length - name.len());
120        console.print(&format!("    {}{}    {}", name, filler, uri))?;
121    }
122    console.print("")?;
123    console.print(&format!("    {} drive(s)", num_drives))?;
124    console.print("")?;
125    Ok(())
126}
127
128/// The `CD` command.
129pub struct CdCommand {
130    metadata: Rc<CallableMetadata>,
131    storage: Rc<RefCell<Storage>>,
132}
133
134impl CdCommand {
135    /// Creates a new `CD` command that changes the current location in `storage`.
136    pub fn new(storage: Rc<RefCell<Storage>>) -> Rc<Self> {
137        Rc::from(Self {
138            metadata: CallableMetadataBuilder::new("CD")
139                .with_syntax(&[(
140                    &[SingularArgSyntax::RequiredValue(
141                        RequiredValueSyntax { name: Cow::Borrowed("path"), vtype: ExprType::Text },
142                        ArgSepSyntax::End,
143                    )],
144                    None,
145                )])
146                .with_category(CATEGORY)
147                .with_description("Changes the current path.")
148                .build(),
149            storage,
150        })
151    }
152}
153
154#[async_trait(?Send)]
155impl Callable for CdCommand {
156    fn metadata(&self) -> Rc<CallableMetadata> {
157        self.metadata.clone()
158    }
159
160    fn exec(&self, scope: Scope<'_>) -> CallResult<()> {
161        debug_assert_eq!(1, scope.nargs());
162        let target = scope.get_string(0);
163
164        self.storage.borrow_mut().cd(target)?;
165
166        Ok(())
167    }
168}
169
170/// The `COPY` command.
171pub struct CopyCommand {
172    metadata: Rc<CallableMetadata>,
173    storage: Rc<RefCell<Storage>>,
174}
175
176impl CopyCommand {
177    /// Creates a new `COPY` command that copies a file.
178    pub fn new(storage: Rc<RefCell<Storage>>) -> Rc<Self> {
179        Rc::from(Self {
180            metadata: CallableMetadataBuilder::new("COPY")
181                .with_async(true)
182                .with_syntax(&[(
183                    &[
184                        SingularArgSyntax::RequiredValue(
185                            RequiredValueSyntax {
186                                name: Cow::Borrowed("src"),
187                                vtype: ExprType::Text,
188                            },
189                            ArgSepSyntax::Exactly(ArgSep::Long),
190                        ),
191                        SingularArgSyntax::RequiredValue(
192                            RequiredValueSyntax {
193                                name: Cow::Borrowed("dest"),
194                                vtype: ExprType::Text,
195                            },
196                            ArgSepSyntax::End,
197                        ),
198                    ],
199                    None,
200                )])
201                .with_category(CATEGORY)
202                .with_description(
203                    "Copies src to dest.
204If dest is a path without a name, the target file given in dest will have the same name \
205as the source file in src.
206See the \"File system\" help topic for information on the path syntax.",
207                )
208                .build(),
209            storage,
210        })
211    }
212}
213
214#[async_trait(?Send)]
215impl Callable for CopyCommand {
216    fn metadata(&self) -> Rc<CallableMetadata> {
217        self.metadata.clone()
218    }
219
220    async fn async_exec(&self, scope: Scope<'_>) -> CallResult<()> {
221        debug_assert_eq!(2, scope.nargs());
222        let src = scope.get_string(0).to_owned();
223        let dest = scope.get_string(1).to_owned();
224
225        let mut storage = self.storage.borrow_mut();
226        storage.copy(&src, &dest).await?;
227
228        Ok(())
229    }
230}
231
232/// The `DIR` command.
233pub struct DirCommand {
234    metadata: Rc<CallableMetadata>,
235    console: Rc<RefCell<dyn Console>>,
236    storage: Rc<RefCell<Storage>>,
237}
238
239impl DirCommand {
240    /// Creates a new `DIR` command that lists `storage` contents on the `console`.
241    pub fn new(console: Rc<RefCell<dyn Console>>, storage: Rc<RefCell<Storage>>) -> Rc<Self> {
242        Rc::from(Self {
243            metadata: CallableMetadataBuilder::new("DIR")
244                .with_async(true)
245                .with_syntax(&[
246                    (&[], None),
247                    (
248                        &[SingularArgSyntax::RequiredValue(
249                            RequiredValueSyntax {
250                                name: Cow::Borrowed("path"),
251                                vtype: ExprType::Text,
252                            },
253                            ArgSepSyntax::End,
254                        )],
255                        None,
256                    ),
257                ])
258                .with_category(CATEGORY)
259                .with_description("Displays the list of files on the current or given path.")
260                .build(),
261            console,
262            storage,
263        })
264    }
265}
266
267#[async_trait(?Send)]
268impl Callable for DirCommand {
269    fn metadata(&self) -> Rc<CallableMetadata> {
270        self.metadata.clone()
271    }
272
273    async fn async_exec(&self, scope: Scope<'_>) -> CallResult<()> {
274        let path = if scope.nargs() == 0 {
275            ""
276        } else {
277            debug_assert_eq!(1, scope.nargs());
278            scope.get_string(0)
279        };
280
281        show_dir(&self.storage.borrow(), &mut *self.console.borrow_mut(), path).await?;
282
283        Ok(())
284    }
285}
286
287/// The `KILL` command.
288pub struct KillCommand {
289    metadata: Rc<CallableMetadata>,
290    storage: Rc<RefCell<Storage>>,
291}
292
293impl KillCommand {
294    /// Creates a new `KILL` command that deletes a file from `storage`.
295    pub fn new(storage: Rc<RefCell<Storage>>) -> Rc<Self> {
296        Rc::from(Self {
297            metadata: CallableMetadataBuilder::new("KILL")
298                .with_async(true)
299                .with_syntax(&[(
300                    &[SingularArgSyntax::RequiredValue(
301                        RequiredValueSyntax {
302                            name: Cow::Borrowed("filename"),
303                            vtype: ExprType::Text,
304                        },
305                        ArgSepSyntax::End,
306                    )],
307                    None,
308                )])
309                .with_category(CATEGORY)
310                .with_description(
311                    "Deletes the given file.
312The filename must be a string and must be a valid EndBASIC path.
313See the \"File system\" help topic for information on the path syntax.",
314                )
315                .build(),
316            storage,
317        })
318    }
319}
320
321#[async_trait(?Send)]
322impl Callable for KillCommand {
323    fn metadata(&self) -> Rc<CallableMetadata> {
324        self.metadata.clone()
325    }
326
327    async fn async_exec(&self, scope: Scope<'_>) -> CallResult<()> {
328        debug_assert_eq!(1, scope.nargs());
329        let name = scope.get_string(0).to_owned();
330
331        self.storage.borrow_mut().delete(&name).await?;
332
333        Ok(())
334    }
335}
336
337/// The `MOUNT` command.
338pub struct MountCommand {
339    metadata: Rc<CallableMetadata>,
340    console: Rc<RefCell<dyn Console>>,
341    storage: Rc<RefCell<Storage>>,
342}
343
344impl MountCommand {
345    /// Creates a new `MOUNT` command.
346    pub fn new(console: Rc<RefCell<dyn Console>>, storage: Rc<RefCell<Storage>>) -> Rc<Self> {
347        Rc::from(Self {
348            metadata: CallableMetadataBuilder::new("MOUNT")
349                .with_syntax(&[
350                    (&[], None),
351                    (
352                        &[
353                            SingularArgSyntax::RequiredValue(
354                                RequiredValueSyntax {
355                                    name: Cow::Borrowed("target"),
356                                    vtype: ExprType::Text,
357                                },
358                                ArgSepSyntax::Exactly(ArgSep::As),
359                            ),
360                            SingularArgSyntax::RequiredValue(
361                                RequiredValueSyntax {
362                                    name: Cow::Borrowed("drive_name"),
363                                    vtype: ExprType::Text,
364                                },
365                                ArgSepSyntax::End,
366                            ),
367                        ],
368                        None,
369                    ),
370                ])
371                .with_category(CATEGORY)
372                .with_description(
373                    "Lists the mounted drives or mounts a new drive.
374With no arguments, prints a list of mounted drives and their targets.
375With two arguments, mounts the drive_name$ to point to the target$.  Drive names are specified \
376without a colon at the end, and targets are given in the form of a URI.",
377                )
378                .build(),
379            console,
380            storage,
381        })
382    }
383}
384
385#[async_trait(?Send)]
386impl Callable for MountCommand {
387    fn metadata(&self) -> Rc<CallableMetadata> {
388        self.metadata.clone()
389    }
390
391    fn exec(&self, scope: Scope<'_>) -> CallResult<()> {
392        if scope.nargs() == 0 {
393            show_drives(&self.storage.borrow(), &mut *self.console.borrow_mut())?;
394            Ok(())
395        } else {
396            debug_assert_eq!(2, scope.nargs());
397            let target = scope.get_string(0).to_owned();
398            let name = scope.get_string(1).to_owned();
399
400            self.storage.borrow_mut().mount(&name, &target)?;
401            Ok(())
402        }
403    }
404}
405
406/// The `PWD` command.
407pub struct PwdCommand {
408    metadata: Rc<CallableMetadata>,
409    console: Rc<RefCell<dyn Console>>,
410    storage: Rc<RefCell<Storage>>,
411}
412
413impl PwdCommand {
414    /// Creates a new `PWD` command that prints the current directory of `storage` to the `console`.
415    pub fn new(console: Rc<RefCell<dyn Console>>, storage: Rc<RefCell<Storage>>) -> Rc<Self> {
416        Rc::from(Self {
417            metadata: CallableMetadataBuilder::new("PWD")
418                .with_syntax(&[(&[], None)])
419                .with_category(CATEGORY)
420                .with_description(
421                    "Prints the current working location.
422If the EndBASIC path representing the current location is backed by a real path that is accessible \
423by the underlying operating system, displays such path as well.",
424                )
425                .build(),
426            console,
427            storage,
428        })
429    }
430}
431
432#[async_trait(?Send)]
433impl Callable for PwdCommand {
434    fn metadata(&self) -> Rc<CallableMetadata> {
435        self.metadata.clone()
436    }
437
438    fn exec(&self, scope: Scope<'_>) -> CallResult<()> {
439        debug_assert_eq!(0, scope.nargs());
440
441        let storage = self.storage.borrow();
442        let cwd = storage.cwd();
443        let system_cwd = storage.system_path(&cwd).expect("cwd must return a valid path");
444
445        let console = &mut *self.console.borrow_mut();
446        console.print("")?;
447        console.print(&format!("    Working directory: {}", cwd))?;
448        match system_cwd {
449            Some(path) => console.print(&format!("    System location: {}", path.display()))?,
450            None => console.print("    No system location available")?,
451        }
452        console.print("")?;
453
454        Ok(())
455    }
456}
457
458/// The `UNMOUNT` command.
459pub struct UnmountCommand {
460    metadata: Rc<CallableMetadata>,
461    storage: Rc<RefCell<Storage>>,
462}
463
464impl UnmountCommand {
465    /// Creates a new `UNMOUNT` command.
466    pub fn new(storage: Rc<RefCell<Storage>>) -> Rc<Self> {
467        Rc::from(Self {
468            metadata: CallableMetadataBuilder::new("UNMOUNT")
469                .with_syntax(&[(
470                    &[SingularArgSyntax::RequiredValue(
471                        RequiredValueSyntax {
472                            name: Cow::Borrowed("drive_name"),
473                            vtype: ExprType::Text,
474                        },
475                        ArgSepSyntax::End,
476                    )],
477                    None,
478                )])
479                .with_category(CATEGORY)
480                .with_description(
481                    "Unmounts the given drive.
482Drive names are specified without a colon at the end.",
483                )
484                .build(),
485            storage,
486        })
487    }
488}
489
490#[async_trait(?Send)]
491impl Callable for UnmountCommand {
492    fn metadata(&self) -> Rc<CallableMetadata> {
493        self.metadata.clone()
494    }
495
496    fn exec(&self, scope: Scope<'_>) -> CallResult<()> {
497        debug_assert_eq!(1, scope.nargs());
498        let drive = scope.get_string(0).to_owned();
499
500        self.storage.borrow_mut().unmount(&drive)?;
501
502        Ok(())
503    }
504}
505
506/// Adds all file system manipulation commands for `storage` to the `machine`, using `console` to
507/// display information.
508pub fn add_all(
509    machine: &mut MachineBuilder,
510    console: Rc<RefCell<dyn Console>>,
511    storage: Rc<RefCell<Storage>>,
512) {
513    machine.add_callable(CdCommand::new(storage.clone()));
514    machine.add_callable(CopyCommand::new(storage.clone()));
515    machine.add_callable(DirCommand::new(console.clone(), storage.clone()));
516    machine.add_callable(KillCommand::new(storage.clone()));
517    machine.add_callable(MountCommand::new(console.clone(), storage.clone()));
518    machine.add_callable(PwdCommand::new(console.clone(), storage.clone()));
519    machine.add_callable(UnmountCommand::new(storage));
520}
521
522#[cfg(test)]
523mod tests {
524    use crate::console::{CharsXY, Key};
525    use crate::storage::{DirectoryDriveFactory, DiskSpace, Drive, InMemoryDrive};
526    use crate::testutils::*;
527    use futures_lite::future::block_on;
528    use std::collections::BTreeMap;
529
530    #[test]
531    fn test_cd_ok() {
532        let mut t = Tester::default();
533        t.get_storage().borrow_mut().mount("other", "memory://").unwrap();
534        t.run("CD \"other:\"").check();
535        assert_eq!("OTHER:/", t.get_storage().borrow().cwd());
536        t.run("CD \"memory:/\"").check();
537        assert_eq!("MEMORY:/", t.get_storage().borrow().cwd());
538    }
539
540    #[test]
541    fn test_cd_errors() {
542        check_stmt_err("1:1: Drive 'A' is not mounted", "CD \"A:\"");
543        check_stmt_compilation_err("1:1: CD expected path$", "CD");
544        check_stmt_compilation_err("1:1: CD expected path$", "CD 2, 3");
545        check_stmt_compilation_err("1:4: Expected STRING but found INTEGER", "CD 2");
546    }
547
548    #[test]
549    fn test_copy_ok() {
550        Tester::default()
551            .set_program(Some("foo.bas"), "Leave me alone")
552            .write_file("file1", "the content")
553            .run(r#"COPY "file1", "file2""#)
554            .expect_program(Some("foo.bas"), "Leave me alone")
555            .expect_file("MEMORY:/file1", "the content")
556            .expect_file("MEMORY:/file2", "the content")
557            .check();
558    }
559
560    #[test]
561    fn test_copy_deduce_target_name() {
562        let t = Tester::default();
563        t.get_storage().borrow_mut().mount("other", "memory://").unwrap();
564        t.set_program(Some("foo.bas"), "Leave me alone")
565            .write_file("file1.x", "the content")
566            .run(r#"COPY "file1.x", "OTHER:/""#)
567            .expect_program(Some("foo.bas"), "Leave me alone")
568            .expect_file("MEMORY:/file1.x", "the content")
569            .expect_file("OTHER:/file1.x", "the content")
570            .check();
571    }
572
573    #[test]
574    fn test_copy_errors() {
575        Tester::default()
576            .run(r#"COPY "foo""#)
577            .expect_compilation_err("1:1: COPY expected src$, dest$")
578            .check();
579
580        Tester::default()
581            .run(r#"COPY "memory:/", "foo.bar""#)
582            .expect_err("1:1: Missing file name in copy source path 'memory:/'")
583            .check();
584
585        Tester::default()
586            .run(r#"COPY "missing.txt", "new.txt""#)
587            .expect_err("1:1: Entry not found")
588            .check();
589
590        Tester::default()
591            .write_file("foo", "irrelevant")
592            .run(r#"COPY "foo", "missing:/""#)
593            .expect_err("1:1: Drive 'MISSING' is not mounted")
594            .expect_file("MEMORY:/foo", "irrelevant")
595            .check();
596
597        //Tester::default()
598        //    .run(r#"KILL "a/b.bas""#)
599        //    .expect_err("1:1: Too many / separators in path 'a/b.bas'")
600        //    .check();
601
602        //Tester::default()
603        //    .run(r#"KILL "drive:""#)
604        //    .expect_err("1:1: Missing file name in path 'drive:'")
605        //    .check();
606
607        //Tester::default()
608        //    .run("KILL")
609        //    .expect_compilation_err("1:1: KILL expected filename$")
610        //    .check();
611
612        //check_stmt_err("1:1: Entry not found", r#"KILL "missing-file""#);
613
614        //Tester::default()
615        //    .write_file("no-automatic-extension.bas", "")
616        //    .run(r#"KILL "no-automatic-extension""#)
617        //    .expect_err("1:1: Entry not found")
618        //    .expect_file("MEMORY:/no-automatic-extension.bas", "")
619        //    .check();
620    }
621
622    #[test]
623    fn test_dir_current_empty() {
624        Tester::default()
625            .run("DIR")
626            .expect_prints([
627                "",
628                "    Directory of MEMORY:/",
629                "",
630                "    Modified              Size    Name",
631                "    0 file(s), 0 bytes",
632                "",
633            ])
634            .check();
635    }
636
637    #[test]
638    fn test_dir_with_disk_free() {
639        let mut other = InMemoryDrive::default();
640        other.fake_disk_quota = Some(DiskSpace::new(456, 0));
641        other.fake_disk_free = Some(DiskSpace::new(123, 0));
642
643        let mut t = Tester::default();
644        t.get_storage().borrow_mut().attach("other", "z://", Box::from(other)).unwrap();
645
646        t.run("DIR \"OTHER:/\"")
647            .expect_prints([
648                "",
649                "    Directory of OTHER:/",
650                "",
651                "    Modified              Size    Name",
652                "    0 file(s), 0 bytes",
653                "    123 of 456 bytes free",
654                "",
655            ])
656            .check();
657    }
658
659    #[test]
660    fn test_dir_current_entries_are_sorted() {
661        Tester::default()
662            .write_file("empty.bas", "")
663            .write_file("some other file.bas", "not empty\n")
664            .write_file("00AAA.BAS", "first\nfile\n")
665            .write_file("not a bas.txt", "")
666            .run("DIR")
667            .expect_prints([
668                "",
669                "    Directory of MEMORY:/",
670                "",
671                "    Modified              Size    Name",
672                "    2020-05-06 09:37        11    00AAA.BAS",
673                "    2020-05-06 09:37         0    empty.bas",
674                "    2020-05-06 09:37         0    not a bas.txt",
675                "    2020-05-06 09:37        10    some other file.bas",
676                "",
677                "    4 file(s), 21 bytes",
678                "",
679            ])
680            .expect_file("MEMORY:/empty.bas", "")
681            .expect_file("MEMORY:/some other file.bas", "not empty\n")
682            .expect_file("MEMORY:/00AAA.BAS", "first\nfile\n")
683            .expect_file("MEMORY:/not a bas.txt", "")
684            .check();
685    }
686
687    #[test]
688    fn test_dir_other_by_argument() {
689        let mut other = InMemoryDrive::default();
690        block_on(other.put("foo.bas", b"hello")).unwrap();
691
692        let mut t = Tester::default().write_file("empty.bas", "");
693        t.get_storage().borrow_mut().attach("other", "z://", Box::from(other)).unwrap();
694
695        let mut prints = vec![
696            "",
697            "    Directory of MEMORY:/",
698            "",
699            "    Modified              Size    Name",
700            "    2020-05-06 09:37         0    empty.bas",
701            "",
702            "    1 file(s), 0 bytes",
703            "",
704        ];
705        t.run("DIR \"memory:\"")
706            .expect_prints(prints.clone())
707            .expect_file("MEMORY:/empty.bas", "")
708            .expect_file("OTHER:/foo.bas", "hello")
709            .check();
710
711        prints.extend([
712            "",
713            "    Directory of OTHER:/",
714            "",
715            "    Modified              Size    Name",
716            "    2020-05-06 09:37         5    foo.bas",
717            "",
718            "    1 file(s), 5 bytes",
719            "",
720        ]);
721        t.run("DIR \"other:/\"")
722            .expect_prints(prints)
723            .expect_file("MEMORY:/empty.bas", "")
724            .expect_file("OTHER:/foo.bas", "hello")
725            .check();
726    }
727
728    #[test]
729    fn test_dir_other_by_cwd() {
730        let mut other = InMemoryDrive::default();
731        block_on(other.put("foo.bas", b"hello")).unwrap();
732
733        let mut t = Tester::default().write_file("empty.bas", "");
734        t.get_storage().borrow_mut().attach("other", "z://", Box::from(other)).unwrap();
735
736        let mut prints = vec![
737            "",
738            "    Directory of MEMORY:/",
739            "",
740            "    Modified              Size    Name",
741            "    2020-05-06 09:37         0    empty.bas",
742            "",
743            "    1 file(s), 0 bytes",
744            "",
745        ];
746        t.run("DIR")
747            .expect_prints(prints.clone())
748            .expect_file("MEMORY:/empty.bas", "")
749            .expect_file("OTHER:/foo.bas", "hello")
750            .check();
751
752        t.get_storage().borrow_mut().cd("other:/").unwrap();
753        prints.extend([
754            "",
755            "    Directory of OTHER:/",
756            "",
757            "    Modified              Size    Name",
758            "    2020-05-06 09:37         5    foo.bas",
759            "",
760            "    1 file(s), 5 bytes",
761            "",
762        ]);
763        t.run("DIR")
764            .expect_prints(prints)
765            .expect_file("MEMORY:/empty.bas", "")
766            .expect_file("OTHER:/foo.bas", "hello")
767            .check();
768    }
769
770    #[test]
771    fn test_dir_narrow_empty() {
772        let mut t = Tester::default();
773        t.get_console().borrow_mut().set_size_chars(CharsXY::new(10, 1));
774        t.run("DIR")
775            .expect_prints(["", "    Directory of MEMORY:/", "", "    0 file(s)", ""])
776            .check();
777    }
778
779    #[test]
780    fn test_dir_narrow_some() {
781        let mut t = Tester::default().write_file("empty.bas", "");
782        t.get_console().borrow_mut().set_size_chars(CharsXY::new(10, 1));
783        t.run("DIR")
784            .expect_prints([
785                "",
786                "    Directory of MEMORY:/",
787                "",
788                "    empty.bas",
789                "",
790                "    1 file(s)",
791                "",
792            ])
793            .expect_file("MEMORY:/empty.bas", "")
794            .check();
795    }
796
797    #[test]
798    fn test_dir_paging() {
799        let t = Tester::default();
800        t.get_console().borrow_mut().set_interactive(true);
801        t.get_console().borrow_mut().set_size_chars(CharsXY { x: 80, y: 7 });
802        t.get_console().borrow_mut().add_input_keys(&[Key::NewLine]);
803        t.write_file("0.bas", "")
804            .write_file("1.bas", "")
805            .write_file("2.bas", "")
806            .write_file("3.bas", "")
807            .run("DIR")
808            .expect_prints([
809                "",
810                "    Directory of MEMORY:/",
811                "",
812                "    Modified              Size    Name",
813                "    2020-05-06 09:37         0    0.bas",
814                "    2020-05-06 09:37         0    1.bas",
815                " << Press any key for more; ESC or Ctrl+C to stop >> ",
816                "    2020-05-06 09:37         0    2.bas",
817                "    2020-05-06 09:37         0    3.bas",
818                "",
819                "    4 file(s), 0 bytes",
820                "",
821            ])
822            .expect_file("MEMORY:/0.bas", "")
823            .expect_file("MEMORY:/1.bas", "")
824            .expect_file("MEMORY:/2.bas", "")
825            .expect_file("MEMORY:/3.bas", "")
826            .check();
827    }
828
829    #[test]
830    fn test_dir_errors() {
831        check_stmt_compilation_err("1:1: DIR expected <> | <path$>", "DIR 2, 3");
832        check_stmt_compilation_err("1:5: Expected STRING but found INTEGER", "DIR 2");
833    }
834
835    #[test]
836    fn test_kill_ok() {
837        for p in &["foo", "foo.bas"] {
838            Tester::default()
839                .set_program(Some(p), "Leave me alone")
840                .write_file("leave-me-alone.bas", "")
841                .write_file(p, "line 1\n  line 2\n")
842                .run(format!(r#"KILL "{}""#, p))
843                .expect_program(Some(*p), "Leave me alone")
844                .expect_file("MEMORY:/leave-me-alone.bas", "")
845                .check();
846        }
847    }
848
849    #[test]
850    fn test_kill_errors() {
851        Tester::default()
852            .run("KILL 3")
853            .expect_compilation_err("1:6: Expected STRING but found INTEGER")
854            .check();
855
856        Tester::default()
857            .run(r#"KILL "a/b.bas""#)
858            .expect_err("1:1: Too many / separators in path 'a/b.bas'")
859            .check();
860
861        Tester::default()
862            .run(r#"KILL "drive:""#)
863            .expect_err("1:1: Missing file name in path 'drive:'")
864            .check();
865
866        Tester::default()
867            .run("KILL")
868            .expect_compilation_err("1:1: KILL expected filename$")
869            .check();
870
871        check_stmt_err("1:1: Entry not found", r#"KILL "missing-file""#);
872
873        Tester::default()
874            .write_file("no-automatic-extension.bas", "")
875            .run(r#"KILL "no-automatic-extension""#)
876            .expect_err("1:1: Entry not found")
877            .expect_file("MEMORY:/no-automatic-extension.bas", "")
878            .check();
879    }
880
881    #[test]
882    fn test_mount_list() {
883        let mut t = Tester::default();
884        let other = InMemoryDrive::default();
885        t.get_storage().borrow_mut().attach("o", "origin://", Box::from(other)).unwrap();
886
887        let mut prints = vec![
888            "",
889            "    Name      Target",
890            "    MEMORY    memory://",
891            "    O         origin://",
892            "",
893            "    2 drive(s)",
894            "",
895        ];
896        t.run("MOUNT").expect_prints(prints.clone()).check();
897
898        t.get_storage().borrow_mut().cd("o:").unwrap();
899        t.get_storage().borrow_mut().unmount("memory").unwrap();
900        prints.extend([
901            "",
902            "    Name    Target",
903            "    O       origin://",
904            "",
905            "    1 drive(s)",
906            "",
907        ]);
908        t.run("MOUNT").expect_prints(prints.clone()).check();
909    }
910
911    #[test]
912    fn test_mount_mount() {
913        let mut t = Tester::default();
914        t.run(r#"MOUNT "memory://" AS "abc""#).check();
915
916        let mut exp_info = BTreeMap::default();
917        exp_info.insert("MEMORY", "memory://");
918        exp_info.insert("ABC", "memory://");
919        assert_eq!(exp_info, t.get_storage().borrow().mounted());
920    }
921
922    #[test]
923    fn test_mount_errors() {
924        check_stmt_compilation_err("1:1: MOUNT expected <> | <target$ AS drive_name$>", "MOUNT 1");
925        check_stmt_compilation_err(
926            "1:1: MOUNT expected <> | <target$ AS drive_name$>",
927            "MOUNT 1, 2, 3",
928        );
929
930        check_stmt_compilation_err("1:14: Expected STRING but found INTEGER", r#"MOUNT "a" AS 1"#);
931        check_stmt_compilation_err("1:7: Expected STRING but found INTEGER", r#"MOUNT 1 AS "a""#);
932
933        check_stmt_err("1:1: Invalid drive name 'a:'", r#"MOUNT "memory://" AS "a:""#);
934        check_stmt_err(
935            "1:1: Mount URI must be of the form scheme://path",
936            r#"MOUNT "foo//bar" AS "a""#,
937        );
938        check_stmt_err("1:1: Unknown mount scheme 'foo'", r#"MOUNT "foo://bar" AS "a""#);
939    }
940
941    #[test]
942    fn test_pwd_without_system_path() {
943        let mut t = Tester::default();
944
945        t.run("PWD")
946            .expect_prints([
947                "",
948                "    Working directory: MEMORY:/",
949                "    No system location available",
950                "",
951            ])
952            .check();
953    }
954
955    #[test]
956    fn test_pwd_with_system_path() {
957        let dir = tempfile::tempdir().unwrap();
958        let dir = dir.path().canonicalize().unwrap();
959
960        let mut t = Tester::default();
961        {
962            let storage = t.get_storage();
963            let storage = &mut *storage.borrow_mut();
964            storage.register_scheme("file", Box::from(DirectoryDriveFactory::default()));
965            storage.mount("other", &format!("file://{}", dir.display())).unwrap();
966            storage.cd("other:/").unwrap();
967        }
968
969        t.run("PWD")
970            .expect_prints([
971                "",
972                "    Working directory: OTHER:/",
973                &format!("    System location: {}", dir.join("").display()),
974                "",
975            ])
976            .check();
977    }
978
979    #[test]
980    fn test_unmount_ok() {
981        let mut t = Tester::default();
982        t.get_storage().borrow_mut().mount("other", "memory://").unwrap();
983        t.get_storage().borrow_mut().cd("other:").unwrap();
984        t.run("UNMOUNT \"memory\"").check();
985
986        let mut exp_info = BTreeMap::default();
987        exp_info.insert("OTHER", "memory://");
988        assert_eq!(exp_info, t.get_storage().borrow().mounted());
989    }
990
991    #[test]
992    fn test_unmount_errors() {
993        check_stmt_compilation_err("1:1: UNMOUNT expected drive_name$", "UNMOUNT");
994        check_stmt_compilation_err("1:1: UNMOUNT expected drive_name$", "UNMOUNT 2, 3");
995
996        check_stmt_compilation_err("1:9: Expected STRING but found INTEGER", "UNMOUNT 1");
997
998        check_stmt_err("1:1: Invalid drive name 'a:'", "UNMOUNT \"a:\"");
999        check_stmt_err("1:1: Drive 'a' is not mounted", "UNMOUNT \"a\"");
1000    }
1001}