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