Skip to main content

endbasic_std/storage/
cmds.rs

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