sibling 3.0.0

get next/previous sibling directory name.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};

use clap::ValueEnum;

use crate::{Dirs, Error, Result};

/// The action taken when the given current directory is not in the target
/// directories, such as the directory given by `--base-path` which does not
/// contain it, and the `current:` line of a list which is not in the list.
///
/// Note that this is not for the unknown current directory; the [`Dirs`] with
/// no current directory means the position before the first one.
/// See [`Nextable::current_index`](crate::Nextable::current_index).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum NotOnDirs {
    /// Fail with [`Error::NotFound`]; the given current directory and the
    /// target directories do not agree with each other.
    #[default]
    Error,
    /// Traverse as if the current directory were before the first one, hence,
    /// the next directory of it is the first of the target directories.
    BeforeFirst,
}

/// Configuration for creating `Dirs` instances.
/// the `base_dir` becoms the parent directory, and
/// the child directories of `base_dir` are listed as sibling directories.
/// If `all_target` is true, non-existent directories in the list are also included as target directories.
/// This flag is used when creating `Dirs` from a file or `Reader``.
pub struct Config {
    pub base_dir: std::path::PathBuf,
    pub all_target: bool,
    pub current: Option<PathBuf>,
    /// The action when `current` is not in the target directories.
    pub not_on_dirs: NotOnDirs,
}

impl Config {
    pub fn new<P: AsRef<Path>>(base_dir: P, all_target: bool) -> Self {
        Config {
            base_dir: base_dir.as_ref().to_path_buf(),
            all_target,
            current: None,
            not_on_dirs: NotOnDirs::default(),
        }
    }

    pub fn new_with_wd<P1: AsRef<Path>, P2: AsRef<Path>>(base_dir: P1, all_target: bool, current: P2) -> Self {
        Config {
            base_dir: base_dir.as_ref().to_path_buf(),
            all_target,
            current: Some(current.as_ref().to_path_buf()),
            not_on_dirs: NotOnDirs::default(),
        }
    }

    /// Set the action taken when the current directory is not in the target
    /// directories.
    #[must_use]
    pub fn not_on_dirs(mut self, action: NotOnDirs) -> Self {
        self.not_on_dirs = action;
        self
    }
}

pub struct DirsFactory {
}

impl DirsFactory {
    /// Creates a new [`Dirs`] instance based on the given base directory.
    /// the `base_dir` becoms the parent directory, and
    /// the child directories of `base_dir` are listed as sibling directories.
    /// 
    /// The resultant `Dirs` instance has no current directory, since no directory
    /// is given as the current one. See [`Nextable::current_index`](crate::Nextable::current_index)
    /// for the traversing from such a position.
    pub fn create<P: AsRef<Path>>(base_dir: P) -> Result<Dirs> {
        Self::create_with(&Config::new(base_dir, false))
    }

    /// Create a new [`Dirs`] instance from the given configuration.
    /// If the current directory is ".", it uses the current working directory.
    ///
    /// # Returns
    ///
    /// A [`Result`]<[`Dirs`], [`Error`]> instance.
    ///
    /// # Errors
    ///
    /// - Returns [`Error::Io`]
    ///   - if `config.base_dir` is not found, or an I/O error occurs while reading the directory.
    /// - Returns [`Error::NotDir`] if the given path is not a directory.
    ///   - if `config.base_dir` is not a directory.
    /// - Returns [`Error::NotFound`] if the given path does not exist.
    ///   - if `config.current` is some, and the directory specified by `config.current` is not found in the siblings.
    pub fn create_with(config: &Config) -> Result<Dirs> {
        let base_dir = &config.base_dir;
        if base_dir == Path::new(".") {
            log::info!("DirsFactory::create_with: Using current directory as base_dir");
            let cwd = std::env::current_dir().map_err(Error::Io)?;
            create_dirs_from_base_path(&cwd, config)
        } else if base_dir.exists() && base_dir.is_dir() {
            create_dirs_from_base_path(base_dir, config)
        } else if base_dir.exists() && !base_dir.is_dir() {
            log::error!("{}:  Not a directory.", base_dir.display());
            Err(Error::NotDir(base_dir.to_path_buf()))
        } else {
            log::error!("{}: directory not found", base_dir.display());
            Err(Error::NotFound(base_dir.to_path_buf()))
        }
    }

    /// Create a new [`Dirs`] instance from the file which lists the directories.
    /// Give `-` as the file name to read the list from stdin.
    /// See [`DirsFactory::create_from_reader`] for the format of the list.
    ///
    /// # Examples
    ///
    /// ```
    /// use sibling::factory::{Config, DirsFactory};
    ///
    /// // the entries of the list are resolved on the base directory of the config,
    /// // until the list gives its own one by the "parent:" line.
    /// let config = Config::new("testdata/basic", false);
    /// let dirs = DirsFactory::create_from_file("testdata/basic/dirlist.txt", &config)
    ///     .expect("Failed to create Dirs");
    /// assert_eq!(dirs.len(), 3); // testdata/basic/{a,b,c}
    /// ```
    ///
    /// # Errors
    ///
    /// - Returns [`Error::NotFound`] if the given file does not exist.
    /// - Returns [`Error::NotFile`] if the given path is a directory.
    /// - Returns [`Error::Io`] if reading the file failed.
    pub fn create_from_file<P: AsRef<Path>>(file: P, config: &Config) -> Result<Dirs> {
        let file = file.as_ref();
        if file == Path::new("-") {
            log::info!("Reading directories from stdin");
            return Self::create_from_reader(
                Box::new(std::io::stdin().lock()),
                config
            );
        }
        if !file.exists() {
            log::error!(
                "Dirs::new_from_file: Not found: {}, pwd: {}",
                file.display(),
                std::env::current_dir().unwrap().display()
            );
            Err(Error::NotFound(file.to_path_buf()))
        } else if file.is_dir() {
            log::error!("Dirs::new_from_file: Not a file: {}", file.display());
            Err(Error::NotFile(file.to_path_buf()))
        } else {
            build_from_list_file(file, config)
        }
    }

    /// Create a new [`Dirs`] instance from the list of the directories.
    ///
    /// Each line of the list is an entry, which is resolved on the base directory.
    /// The `parent:` (or `base_dir:`) line gives the base directory of the following
    /// entries, and the `current:` line gives the current directory; the path of it
    /// is used as is, and it is also added to the list. The empty lines and the lines
    /// starting with `#` are ignored.
    ///
    /// The current directory is decided by the following order.
    ///
    /// 1. the `current:` line, or `config.current`, if it is given,
    /// 2. the working directory, if it is in the list, or
    /// 3. unknown; see [`Nextable::current_index`](crate::Nextable::current_index).
    ///
    /// # Errors
    ///
    /// - Returns [`Error::Io`] if reading the list failed.
    /// - Returns [`Error::NotFound`] if the given current directory is not in the list.
    pub fn create_from_reader(reader: Box<dyn std::io::Read>, config: &Config) -> Result<Dirs> {
        let mut base_dir = config.base_dir.clone();
        let mut dirs = vec![];
        let mut current = config.current.clone();
        let buf_reader = BufReader::new(reader);
        for line in buf_reader.lines() {
            let line = line.map_err(Error::Io)?;
            let line = line.trim();
            if line.is_empty() || line.starts_with('#') {
                continue;
            } else if line.starts_with("base_dir:") || line.starts_with("parent:") {
                base_dir = std::path::PathBuf::from(
                    line.split_once(':').unwrap().1.trim(),
                );
            } else if line.starts_with("current:") {
                let child = PathBuf::from(line.split_once(':').unwrap().1.trim());
                append_dirs(&mut dirs, child.clone(), config);
                current = Some(child);

            } else {
                let path = base_dir.join(line);
                append_dirs(&mut dirs, path, config);
            }
        }
        let current = if current.is_some() {
            resolve_current(&base_dir, &dirs, &current, config.not_on_dirs)?
        } else {
            // No current directory was given by the `current:` line nor the config.
            // Then, the working directory becomes the current one if it is in the
            // list; otherwise the current directory is left unknown.
            find_cwd(&dirs)
        };
        Ok(Dirs {
            entries: dirs,
            parent: base_dir,
            current,
        })
    }
}

fn append_dirs(dirs: &mut Vec<PathBuf>, path: PathBuf, config: &Config) {
    if path.exists() {
        dirs.push(path);
    } else if config.all_target {
        log::info!("append_dirs: Non-existent directory: {}", path.display());
        dirs.push(path);
    } else {
        log::info!("append_dirs: Skipping non-existent directory: {}", path.display());
    }
}

fn build_from_list_file<P: AsRef<Path>>(file: P, config: &Config) -> Result<Dirs> {
    let file = file.as_ref();
    if let Ok(f) = std::fs::File::open(file) {
        let reader = BufReader::new(f);
        DirsFactory::create_from_reader(Box::new(reader), config)
    } else {
        log::error!("build_from_list_file: I/O error: {}", file.display());
        Err(Error::Io(std::io::Error::last_os_error()))
    }
}

fn create_dirs_from_base_path(parent: &Path, config: &Config) -> Result<Dirs> {
    log::trace!("build_dirs_from_base_path(parent={parent:?})");
    let mut errs = vec![];
    let entries = collect_dirs(parent, &mut errs);
    if errs.is_empty() {
        let current = resolve_current(parent, &entries, &config.current, config.not_on_dirs)?;
        log::info!("build_dirs: siblings={}, current={current:?}", entries.len());
        Ok(Dirs {
            entries,
            parent: parent.to_path_buf(),
            current,
        })
    } else {
        Err(Error::Array(errs))
    }
}

/// Find the index of the given current directory, and take the action of
/// `not_on_dirs` if it is not in `dirs`.
///
/// # Errors
///
/// Returns [`Error::NotFound`] if the current directory is not in `dirs`, and
/// `not_on_dirs` is [`NotOnDirs::Error`].
fn resolve_current(
    base_dir: &Path,
    dirs: &[PathBuf],
    current: &Option<PathBuf>,
    not_on_dirs: NotOnDirs,
) -> Result<Option<usize>> {
    let index = find_current(base_dir, dirs, current);
    match (index, current) {
        (None, Some(current)) if not_on_dirs == NotOnDirs::Error => {
            log::error!("{}: not in the target directories", current.display());
            Err(Error::NotFound(current.clone()))
        }
        _ => Ok(index),
    }
}

/// Return the index of `current` in `dirs`, or [`None`] if it is not in them.
pub(super) fn find_current(base_dir: &Path, dirs: &[PathBuf], current: &Option<PathBuf>) -> Option<usize> {
    let Some(current) = current else {
        log::debug!("find_current: no current directory was given");
        return None;
    };
    let wd = current.strip_prefix(base_dir)
        .unwrap_or(current);
    let index = dirs.iter().position(|dir|{
        let td = dir.strip_prefix(base_dir);
        td.map(|d| wd == d).unwrap_or(false)
    });
    if index.is_none() {
        log::warn!("find_current: current directory not found in siblings");
    }
    index
}

/// Return the index of the current working directory in `dirs`, or [`None`] if
/// it is not in them.
///
/// Unlike [`find_current`], the paths are compared as the canonicalized ones,
/// since the entries of the list are given by the user, and they may be relative
/// to the working directory.
fn find_cwd(dirs: &[PathBuf]) -> Option<usize> {
    let cwd = std::env::current_dir()
        .and_then(std::fs::canonicalize)
        .ok()?;
    let index = dirs
        .iter()
        .position(|dir| std::fs::canonicalize(dir).is_ok_and(|d| d == cwd));
    match index {
        Some(index) => log::debug!("find_cwd: the current directory is at {index}"),
        None => log::debug!("find_cwd: the current directory is not in the list"),
    }
    index
}

/// Read child directories under parent, push IO errors to errs, and return a sorted list.
fn collect_dirs(parent: &Path, errs: &mut Vec<Error>) -> Vec<PathBuf> {
    log::trace!("collect_dirs(parent={})", parent.display());
    let mut dirs = vec![];
    match parent.read_dir() {
        Ok(entries) => {
            for entry in entries {
                match entry {
                    Ok(entry) => {
                        let path = entry.path();
                        if path.is_dir() {
                            dirs.push(path);
                        }
                    }
                    Err(e) => {
                        log::error!("collect_dirs: I/O error: {e}");
                        errs.push(Error::Io(e));
                    }
                }
            }
        }
        Err(e) => {
            log::error!("collect_dirs: {}: {e}", parent.display());
            errs.push(Error::Io(e));
        }
    }
    if log::log_enabled!(log::Level::Warn) && dirs.is_empty() {
        log::warn!("collect_dirs: no directories under {}", parent.display());
    }
    dirs.sort();
    dirs
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_create_dirs() {
        let dirs = DirsFactory::create("testdata/basic")
            .expect("Failed to create Dirs from testdata/basic");
        assert_eq!(dirs.len(), 26);
        assert_eq!(dirs.parent, PathBuf::from("testdata/basic"));
        assert!(dirs.current().is_none());
    }

    #[test]
    fn test_create_dirs_with() {
        let config = Config::new_with_wd(PathBuf::from("testdata/basic"), false, "c");
        let dirs = DirsFactory::create_with(&config)
            .expect("Failed to create Dirs from testdata/basic");
        assert_eq!(dirs.len(), 26);
        assert_eq!(dirs.parent, PathBuf::from("testdata/basic"));
        let wd = dirs.current().expect("no current directory");
        assert_eq!(wd.path(), Path::new("testdata/basic/c"));
        assert_eq!(wd.index(), 2);
    }

    #[test]
    fn test_create_dirs_from_file() {
        let config = Config::new(PathBuf::from("testdata/basic"), false);
        let dirs = DirsFactory::create_from_file("testdata/basic/dirlist.txt", &config)
            .expect("Failed to create Dirs from testdata/basic/dirlist.txt");
        assert_eq!(dirs.len(), 3);
        assert_eq!(dirs.parent, PathBuf::from("testdata/basic"));
        assert!(dirs.current().is_none());
    }

    /// The `current:` line in the list gives the current directory.
    #[test]
    fn test_create_dirs_from_reader_with_current() {
        let list = "parent: testdata/basic\na\ncurrent: testdata/basic/b\nc\n";
        let dirs = DirsFactory::create_from_reader(
            Box::new(std::io::Cursor::new(list)),
            &Config::new(".", false),
        )
        .expect("Failed to create Dirs from the reader");

        assert_eq!(dirs.len(), 3);
        let current = dirs.current().expect("no current directory");
        assert_eq!(current.path(), Path::new("testdata/basic/b"));
        assert_eq!(current.index(), 1);
    }

    /// The working directory becomes the current one if it is in the list, even
    /// though the list has no `current:` line.
    #[test]
    fn test_create_dirs_from_reader_on_cwd() {
        use crate::Nextable;

        let cwd = std::env::current_dir().unwrap();
        let name = cwd.file_name().unwrap().to_string_lossy().to_string();
        let list = format!("parent: ..\n{name}\nzzz_no_such_dir\n");
        let dirs = DirsFactory::create_from_reader(
            Box::new(std::io::Cursor::new(list)),
            &Config::new(".", true), // --all, for the non-existent directory
        )
        .expect("Failed to create Dirs from the reader");

        assert_eq!(dirs.len(), 2);
        assert_eq!(dirs.current().map(|d| d.index()), Some(0));
        assert_eq!(
            dirs.next(crate::NexterType::Next).map(|d| d.path().to_path_buf()),
            Some(PathBuf::from("../zzz_no_such_dir"))
        );
    }

    /// The current directory is left unknown if it is not in the list.
    #[test]
    fn test_create_dirs_from_reader_without_current() {
        let list = "parent: testdata/basic\na\nb\n";
        let dirs = DirsFactory::create_from_reader(
            Box::new(std::io::Cursor::new(list)),
            &Config::new(".", false),
        )
        .expect("Failed to create Dirs from the reader");

        assert_eq!(dirs.len(), 2);
        assert!(dirs.current().is_none());
    }

    /// The given current directory is not in the target ones; the action of
    /// the config decides what happens.
    #[test]
    fn test_not_on_dirs() {
        let config = Config::new_with_wd("testdata/worried", false, "testdata/basic/c");
        let e = DirsFactory::create_with(&config)
            .expect_err("the current directory is not in testdata/worried");
        assert!(matches!(e, Error::NotFound(_)), "{e}");

        let config = config.not_on_dirs(NotOnDirs::BeforeFirst);
        let dirs = DirsFactory::create_with(&config)
            .expect("before-first accepts the current directory out of the targets");
        assert!(dirs.current().is_none());
        assert_eq!(dirs.len(), 2);
    }

    /// The working directory found by the list is not the given one, hence,
    /// the action does not affect it.
    #[test]
    fn test_not_on_dirs_does_not_affect_the_unknown_current() {
        let list = "parent: testdata/basic\na\nb\n";
        let dirs = DirsFactory::create_from_reader(
            Box::new(std::io::Cursor::new(list)),
            &Config::new(".", false), // NotOnDirs::Error by default
        )
        .expect("the unknown current directory is not an error");
        assert!(dirs.current().is_none());
    }

    #[test]
    fn test_fail_to_create_dirs_by_non_existent_dir() {
        let _ = DirsFactory::create("src/not_exist_dir")
            .expect_err("Expected error when creating Dirs from a non-existent directory");
    }

    #[test]
    fn test_fail_to_create_dirs_by_non_existent_file() {
        let _ = DirsFactory::create_from_file("src/not_exist_file.txt", &Config::new(".", false))
            .expect_err("Expected error when creating Dirs from a non-existent file");
    }

    #[test]
    fn test_fail_to_create_dirs_by_gives_file() {
        let _ = DirsFactory::create("src/lib.rs")
            .expect_err("Expected error when creating Dirs from a file");
    }

    #[test]
    fn test_fail_to_create_dirs_from_file_by_gives_dir() {
        let _ = DirsFactory::create_from_file("src", &Config::new(".", false))
            .expect_err("Expected error when creating Dirs from a directory");
    }
}