diff --git a/README.md b/README.md index cc00401..fb10919 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,13 @@ upmd --ci --all README.md ``` > [!TIP] -> With no path, `upmd` opens `up.md` or `UP.md` from the current directory when either is present. Otherwise, it opens the current-directory file picker. Directories are searched recursively, while piped stdin is read as Markdown. A failed task or startup error produces a non-zero exit status. +> No path: opens `up.md`/`UP.md` or the file picker. Directory searches are recursive. Piped stdin is Markdown. Run `upmd --help` for all command-line options. +> [!TIP] +> Tasks start in the current directory by default. Use `-d DIR` to choose an initial directory, or `-d :auto` to use the Markdown file's directory. For stdin, `:auto` uses the current directory. + ## Tasks and workflows The workflow in [`DEMO.md`](DEMO.md) uses `name` and `deps` attributes after the language token: diff --git a/src/apps/cli/app.rs b/src/apps/cli/app.rs index 92398c1..f35f3b0 100644 --- a/src/apps/cli/app.rs +++ b/src/apps/cli/app.rs @@ -87,8 +87,7 @@ impl App { // see mutations from prior blocks (like cd or export). let envs = std::env::vars().collect(); let cwd = config - .working_dir - .clone() + .resolve_working_dir() .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); Self { @@ -143,6 +142,10 @@ impl App { return Some(Cmd::quit()); } self.picker = None; + self.config.file = Some(path.display().to_string()); + if let Some(cwd) = self.config.resolve_working_dir() { + self.cwd = cwd; + } } Err(err) => { // In picker mode, errors are fatal since there's no diff --git a/src/apps/config.rs b/src/apps/config.rs index ae53641..e38d4d2 100644 --- a/src/apps/config.rs +++ b/src/apps/config.rs @@ -2,7 +2,7 @@ use crate::apps::theme::Theme; use keymap::{DerivedConfig, KeyMapConfig}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; pub const LOGO: [&str; 4] = [ " ▄", @@ -581,6 +581,13 @@ impl UserConfig { } } +/// Working-directory selection for code execution. +#[derive(Debug, Clone)] +pub enum WorkingDir { + Auto, + Path(PathBuf), +} + /// Merged application configuration from CLI arguments, user config, and defaults. #[derive(Debug, Default, Clone)] pub struct Config { @@ -600,7 +607,7 @@ pub struct Config { /// Per-language binary overrides. pub binaries: HashMap, /// Working directory for code execution. - pub working_dir: Option, + pub working_dir: Option, } pub type Envs = BTreeMap; @@ -623,10 +630,24 @@ pub struct ConfigArgs { pub transparent: bool, pub keymap: KeymapConfig, pub binaries: HashMap, - pub working_dir: Option, + pub working_dir: Option, } impl Config { + /// Resolves the configured directory against the active Markdown file. + pub fn resolve_working_dir(&self) -> Option { + match self.working_dir.as_ref()? { + WorkingDir::Auto => self.file.as_deref().map(|file| { + Path::new(file) + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .to_path_buf() + }), + WorkingDir::Path(path) => Some(path.clone()), + } + } + pub fn new(args: ConfigArgs) -> Self { Self { theme: Theme::new(&args.theme, args.transparent), diff --git a/src/apps/tui/app.rs b/src/apps/tui/app.rs index c82eecf..953f725 100644 --- a/src/apps/tui/app.rs +++ b/src/apps/tui/app.rs @@ -60,7 +60,7 @@ pub struct App { file_picker_root: Option, output: tui::output::Output, workflow: WorkflowState, - last_cwd: Option, + cwd: Option, started: bool, /// Transient flash notification at the bottom right. notification: Option, @@ -160,7 +160,9 @@ impl App { file_picker_root: None, output: tui::output::Output::new(config.keymap.output()), workflow: WorkflowState::default(), - last_cwd: None, + cwd: config + .resolve_working_dir() + .or_else(|| std::env::current_dir().ok()), notification: None, started: false, } @@ -181,10 +183,7 @@ impl App { envs, self.config.capture_state, &self.config.binaries, - self.config - .working_dir - .clone() - .or_else(|| self.last_cwd.clone()), + self.cwd.clone(), ) .map(|receiver| exec::stream_rx(id, receiver, Msg::StreamUpdate)); @@ -333,7 +332,7 @@ impl App { self.envs.merge_envs(env); } if let Some(cwd) = cwd { - self.last_cwd = Some(cwd); + self.cwd = Some(cwd); } return; } @@ -347,7 +346,7 @@ impl App { self.envs.merge_envs(envs); } if let Some(cwd) = state.cwd { - self.last_cwd = Some(cwd); + self.cwd = Some(cwd); } } } @@ -1342,6 +1341,9 @@ impl App { match self.load_document(doc) { Ok(()) => { self.config.file = Some(path.display().to_string()); + if let Some(cwd) = self.config.resolve_working_dir() { + self.cwd = Some(cwd); + } self.overlay = None; self.view = View::Home; } diff --git a/src/args.rs b/src/args.rs index 5d433cb..61056ae 100644 --- a/src/args.rs +++ b/src/args.rs @@ -1,8 +1,6 @@ -use std::path::PathBuf; - use clap::{ArgAction, Parser}; -use crate::apps::config::{Config, ConfigArgs, UserConfig}; +use crate::apps::config::{Config, ConfigArgs, UserConfig, WorkingDir}; /// Run code blocks from Markdown in the terminal. #[derive(Parser, Debug)] @@ -23,7 +21,7 @@ pub struct Args { #[arg(long, default_value_t = false)] pub ci: bool, - /// Working directory for code execution (default: current directory). + /// Initial working directory for code execution (:auto uses the Markdown file's directory). #[arg(short = 'd', long, value_name = "DIR")] pub working_dir: Option, @@ -91,7 +89,10 @@ impl From for Config { /// CLI flags explicitly set by the user take precedence over the config file. pub fn build_config(args: Args, user_cfg: UserConfig) -> Config { let theme = args.theme.unwrap_or_else(|| user_cfg.theme().to_string()); - let working_dir = args.working_dir.map(PathBuf::from); + let working_dir = args.working_dir.map(|dir| match dir.as_str() { + ":auto" => WorkingDir::Auto, + _ => WorkingDir::Path(dir.into()), + }); let transparent = args .tui_args .transparent diff --git a/tests/integration.rs b/tests/integration.rs index 7b91fee..8a66aeb 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -48,6 +48,30 @@ printf 'FINAL-PTY-TAIL\n' ); } +#[cfg(unix)] +#[test] +fn auto_working_directory_uses_markdown_parent() { + let tmp = tempfile::tempdir().unwrap(); + let docs = tmp.path().join("docs"); + std::fs::create_dir(&docs).unwrap(); + std::fs::write(docs.join("marker.txt"), "markdown-directory\n").unwrap(); + std::fs::write(docs.join("run.md"), "```sh\ncat marker.txt\n```\n").unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_upmd")) + .current_dir(tmp.path()) + .args(["docs/run.md", "--ci", "-b", "1", "-d", ":auto"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(output.status.success(), "{stdout}\n{stderr}"); + assert!( + stdout.lines().any(|line| line == "markdown-directory"), + "{stdout}" + ); +} + #[cfg(unix)] #[test] fn missing_block_fails_before_either_frontend_starts() {