Skip to content

Commit

Permalink
Search parent directories for collection file
Browse files Browse the repository at this point in the history
- If a collection file isn't found in the current directory, recursively search our parents (up to the fs root) to find it.
- Print a notification on startup to show the loaded collection file (similar to what shows after a reload)

Closes #194
  • Loading branch information
LucasPickering committed May 1, 2024
1 parent d484ad6 commit e386653
Show file tree
Hide file tree
Showing 10 changed files with 170 additions and 54 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

- Reduce UI latency under certain scenarios
- Previously some actions would feel laggy because of an inherent 250ms delay in processing some events
- Search parent directories for collection file ([#194](https://github.com/LucasPickering/slumber/issues/194))

### Fixed

Expand Down
4 changes: 2 additions & 2 deletions docs/src/api/request_collection/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ Collection files are designed to be sharable, meaning you can commit them to you

## Format & Loading

A collection is defined as a [YAML](https://yaml.org/) file. When you run `slumber`, it will search the current directory for the following default collection files, in order:
A collection is defined as a [YAML](https://yaml.org/) file. When you run `slumber`, it will search the current directory _and its parents_ for the following default collection files, in order:

- `slumber.yml`
- `slumber.yaml`
- `.slumber.yml`
- `.slumber.yaml`

Whichever of those files is found _first_ will be used. If you want to use a different file for your collection (e.g. if you want to store multiple collections in the same directory), you can override the auto-search with the `--file` (or `-f`) command line argument. E.g.:
Whichever of those files is found _first_ will be used. For any given directory, if no collection file is found there, it will recursively go up the directory tree until we find a collection file or hit the root directory. If you want to use a different file for your collection (e.g. if you want to store multiple collections in the same directory), you can override the auto-search with the `--file` (or `-f`) command line argument. E.g.:

```sh
slumber -f my-collection.yml
Expand Down
2 changes: 1 addition & 1 deletion src/cli/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ impl BuildRequestCommand {
global: GlobalArgs,
trigger_dependencies: bool,
) -> anyhow::Result<(Option<HttpEngine>, Request)> {
let collection_path = CollectionFile::try_path(global.file)?;
let collection_path = CollectionFile::try_path(None, global.file)?;
let database = Database::load()?.into_collection(&collection_path)?;
let collection_file = CollectionFile::load(collection_path).await?;
let collection = collection_file.collection;
Expand Down
6 changes: 4 additions & 2 deletions src/cli/show.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ impl Subcommand for ShowCommand {
async fn execute(self, global: GlobalArgs) -> anyhow::Result<ExitCode> {
match self.target {
ShowTarget::Paths => {
let collection_path = CollectionFile::try_path(global.file);
let collection_path =
CollectionFile::try_path(None, global.file);
println!("Data directory: {}", DataDirectory::root());
println!("Log file: {}", DataDirectory::log());
println!("Config: {}", Config::path());
Expand All @@ -47,7 +48,8 @@ impl Subcommand for ShowCommand {
println!("{}", to_yaml(&config));
}
ShowTarget::Collection => {
let collection_path = CollectionFile::try_path(global.file)?;
let collection_path =
CollectionFile::try_path(None, global.file)?;
let collection_file =
CollectionFile::load(collection_path).await?;
println!("{}", to_yaml(&collection_file.collection));
Expand Down
135 changes: 110 additions & 25 deletions src/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ pub use recipe_tree::*;

use crate::util::{parse_yaml, ResultExt};
use anyhow::{anyhow, Context};
use itertools::Itertools;
use std::{
env,
fmt::Debug,
fs,
future::Future,
path::{Path, PathBuf},
};
use tokio::task;
use tracing::{info, warn};
use tracing::{info, trace, warn};

/// The support file names to be automatically loaded as a config. We only
/// support loading from one file at a time, so if more than one of these is
Expand Down Expand Up @@ -72,37 +74,68 @@ impl CollectionFile {

/// Get the path to the collection file, returning an error if none is
/// available. This will use the override if given, otherwise it will fall
/// back to searching the current directory for a collection.
pub fn try_path(override_path: Option<PathBuf>) -> anyhow::Result<PathBuf> {
override_path.or_else(detect_path).ok_or(anyhow!(
"No collection file given and none found in current directory"
))
/// back to searching the given directory for a collection. If the directory
/// to search is not given, default to the current directory. This is
/// configurable just for testing.
pub fn try_path(
dir: Option<PathBuf>,
override_path: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
let dir = if let Some(dir) = dir {
dir
} else {
env::current_dir()?
};
override_path
.map(|override_path| dir.join(override_path))
.or_else(|| detect_path(&dir)).ok_or_else(|| {
anyhow!("No collection file found in current or ancestor directories")
})
}
}

/// Search the current directory for a config file matching one of the known
/// file names, and return it if found
fn detect_path() -> Option<PathBuf> {
let paths: Vec<&Path> = CONFIG_FILES
.iter()
.map(Path::new)
// This could be async but I'm being lazy and skipping it for now,
// since we only do this at startup anyway (mid-process reloading
// reuses the detected path so we don't re-detect)
.filter(|p| p.exists())
.collect();
match paths.as_slice() {
[] => None,
[path] => Some(path.to_path_buf()),
[first, rest @ ..] => {
// Print a warning, but don't actually fail
warn!(
"Multiple config files detected. {first:?} will be used \
and the following will be ignored: {rest:?}"
);
Some(first.to_path_buf())
fn detect_path(dir: &Path) -> Option<PathBuf> {
/// Search a directory and its parents for the collection file. Return None
/// only if we got through the whole tree and couldn't find it
fn search_all(dir: &Path) -> Option<PathBuf> {
search(dir).or_else(|| {
let parent = dir.parent()?;
search_all(parent)
})
}

/// Search a single directory for a collection file
fn search(dir: &Path) -> Option<PathBuf> {
trace!("Scanning for collection file in {dir:?}");

let paths = CONFIG_FILES
.iter()
.map(|file| dir.join(file))
// This could be async but I'm being lazy and skipping it for now,
// since we only do this at startup anyway (mid-process reloading
// reuses the detected path so we don't re-detect)
.filter(|p| p.exists())
.collect_vec();
match paths.as_slice() {
[] => None,
[first, rest @ ..] => {
if !rest.is_empty() {
warn!(
"Multiple collection files detected. {first:?} will be \
used and the following will be ignored: {rest:?}"
);
}

trace!("Found collection file at {first:?}");
Some(first.to_path_buf())
}
}
}

// Walk *up* the tree until we've hit the root
search_all(dir)
}

/// Load a collection from the given file. Takes an owned path because it
Expand Down Expand Up @@ -131,3 +164,55 @@ async fn load_collection(path: PathBuf) -> anyhow::Result<Collection> {

result.context(error_context).traced()
}

#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::*;
use rstest::rstest;
use std::fs::File;

/// Test various cases of try_path
#[rstest]
#[case::parent_only(None, true, false, "slumber.yml")]
#[case::child_only(None, false, true, "child/slumber.yml")]
#[case::parent_and_child(None, true, true, "child/slumber.yml")]
#[case::overriden(Some("override.yml"), true, true, "child/override.yml")]
fn test_try_path(
temp_dir: PathBuf,
#[case] override_file: Option<&str>,
#[case] has_parent: bool,
#[case] has_child: bool,
#[case] expected: &str,
) {
let child_dir = temp_dir.join("child");
fs::create_dir(&child_dir).unwrap();
let file = "slumber.yml";
if has_parent {
File::create(temp_dir.join(file)).unwrap();
}
if has_child {
File::create(child_dir.join(file)).unwrap();
}
if let Some(override_file) = override_file {
File::create(temp_dir.join(override_file)).unwrap();
}
let expected: PathBuf = temp_dir.join(expected);

let override_file = override_file.map(PathBuf::from);
assert_eq!(
CollectionFile::try_path(Some(child_dir), override_file).unwrap(),
expected
);
}

/// Test that try_path fails when no collection file is found and no
/// override is given
#[rstest]
fn test_try_path_error(temp_dir: PathBuf) {
assert_err!(
CollectionFile::try_path(Some(temp_dir), None),
"No collection file found in current or ancestor directories"
);
}
}
25 changes: 15 additions & 10 deletions src/collection/insomnia.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,28 +461,33 @@ where
#[cfg(test)]
mod tests {
use super::*;
use crate::collection::CollectionFile;
use crate::{collection::CollectionFile, test_util::*};
use indexmap::indexmap;
use pretty_assertions::assert_eq;
use rstest::rstest;
use serde::de::DeserializeOwned;
use serde_test::{assert_de_tokens, assert_de_tokens_error, Token};
use std::fmt::Debug;
use std::{fmt::Debug, path::PathBuf};

const INSOMNIA_FILE: &str = "./test_data/insomnia.json";
const INSOMNIA_FILE: &str = "insomnia.json";
/// Assertion expectation is stored in a separate file. This is for a couple
/// reasons:
/// - It's huge so it makes code hard to navigate
/// - Changes don't require a re-compile
const INSOMNIA_IMPORTED_FILE: &str = "./test_data/insomnia_imported.yml";
const INSOMNIA_IMPORTED_FILE: &str = "insomnia_imported.yml";

/// Catch-all test for insomnia import
#[rstest]
#[tokio::test]
async fn test_insomnia_import() {
let imported = Collection::from_insomnia(INSOMNIA_FILE).unwrap();
let expected = CollectionFile::load(INSOMNIA_IMPORTED_FILE.into())
.await
.unwrap()
.collection;
async fn test_insomnia_import(test_data_dir: PathBuf) {
let imported =
Collection::from_insomnia(test_data_dir.join(INSOMNIA_FILE))
.unwrap();
let expected =
CollectionFile::load(test_data_dir.join(INSOMNIA_IMPORTED_FILE))
.await
.unwrap()
.collection;
assert_eq!(imported, expected);
}

Expand Down
20 changes: 20 additions & 0 deletions src/test_util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,21 @@ factori!(TemplateContext, {
}
});

/// Directory containing static test data
#[rstest::fixture]
pub fn test_data_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("test_data")
}

/// Create a new temporary folder. This will include a random subfolder to
/// guarantee uniqueness for this test.
#[rstest::fixture]
pub fn temp_dir() -> PathBuf {
let path = env::temp_dir().join(Uuid::new_v4().to_string());
fs::create_dir(&path).unwrap();
path
}

/// Return a static value when prompted, or no value if none is given
#[derive(Debug, Default)]
pub struct TestPrompter {
Expand Down Expand Up @@ -204,3 +219,8 @@ macro_rules! assert_err {
}};
}
pub(crate) use assert_err;
use std::{
env, fs,
path::{Path, PathBuf},
};
use uuid::Uuid;
10 changes: 3 additions & 7 deletions src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ impl Tui {
/// because they prevent TUI execution.
pub async fn start(collection_path: Option<PathBuf>) -> anyhow::Result<()> {
initialize_panic_handler();
let collection_path = CollectionFile::try_path(collection_path)?;
let collection_path = CollectionFile::try_path(None, collection_path)?;

// ===== Initialize global state =====
// This stuff only needs to be set up *once per session*
Expand All @@ -88,7 +88,7 @@ impl Tui {
TuiContext::send_message(Message::Error { error });
CollectionFile::with_path(collection_path)
});
let view = View::new(&collection_file.collection);
let view = View::new(&collection_file);

// The code to revert the terminal takeover is in `Tui::drop`, so we
// shouldn't take over the terminal until right before creating the
Expand Down Expand Up @@ -338,12 +338,8 @@ impl Tui {
// old one *first* to make sure UI state is saved before being restored
self.view.replace(|old| {
drop(old);
View::new(&self.collection_file.collection)
View::new(&self.collection_file)
});
self.view.notify(format!(
"Reloaded collection from {}",
self.collection_file.path().to_string_lossy()
));
}

/// GOODBYE
Expand Down
15 changes: 10 additions & 5 deletions src/tui/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ pub use theme::Theme;
pub use util::PreviewPrompter;

use crate::{
collection::{Collection, ProfileId, RecipeId},
collection::{CollectionFile, ProfileId, RecipeId},
tui::{
context::TuiContext,
input::Action,
Expand Down Expand Up @@ -47,10 +47,15 @@ pub struct View {
}

impl View {
pub fn new(collection: &Collection) -> Self {
Self {
root: Root::new(collection).into(),
}
pub fn new(collection_file: &CollectionFile) -> Self {
let mut view = Self {
root: Root::new(&collection_file.collection).into(),
};
view.notify(format!(
"Loaded collection from {}",
collection_file.path().to_string_lossy()
));
view
}

/// Draw the view to screen. This needs access to the input engine in order
Expand Down
6 changes: 4 additions & 2 deletions src/util/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ pub struct DataDirectory(PathBuf);

impl DataDirectory {
/// Root directory for all generated files. The value is contextual:
/// - In development, use a directory in the current directory
/// - In development, use a directory from the crate root
/// - In release, use a platform-specific directory in the user's home
pub fn root() -> Self {
if cfg!(debug_assertions) {
Self("./data/".into())
// If env var isn't defined, this will just become ./data/
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("data/");
Self(path)
} else {
// According to the docs, this dir will be present on all platforms
// https://docs.rs/dirs/latest/dirs/fn.data_dir.html
Expand Down

0 comments on commit e386653

Please sign in to comment.