updated CLI

This commit is contained in:
thePR0M3TH3AN
2025-05-15 16:37:59 -04:00
parent 84df958978
commit a38f613a79
13 changed files with 443 additions and 57 deletions

View File

@@ -145,26 +145,26 @@ Paste & run each block in your terminal.
---
### 0Prepare & build
### 0Prepare, build & install
```bash
# Clone or cd into your Marlin repo
cd ~/Documents/GitHub/Marlin
# Build the release binary
cargo build --release
```
sudo install -Dm755 target/release/marlin /usr/local/bin/marlin
````
> Now `marlin` is available everywhere.
---
### 1Install on your PATH
### 1Enable shell completion (optional but handy)
```bash
sudo install -Dm755 target/release/marlin /usr/local/bin/marlin
marlin completions bash > ~/.config/bash_completion.d/marlin
# or for zsh / fish, similarly...
```
> Now `marlin` is available everywhere.
---
### 2Prepare a clean demo directory
@@ -184,16 +184,14 @@ printf "fake jpg\n" > ~/marlin_demo/Media/Photos/vacation.jpg
### 3Initialize & index files
```bash
# Use --verbose if you want full debug traces:
marlin init
marlin scan ~/marlin_demo
# or, to see every path tested:
marlin --verbose init
# show every path tested:
marlin --verbose scan ~/marlin_demo
```
> **Tip:** Rerun `marlin scan` after you add/remove/modify files; only changed files get re-indexed.
> Only changed files get re-indexed on subsequent runs.
---
@@ -204,11 +202,10 @@ marlin --verbose scan ~/marlin_demo
marlin tag "~/marlin_demo/Projects/Alpha/**/*" project/alpha
# Mark all PDFs as reviewed
marlin attr set "~/marlin_demo/**/*.pdf" reviewed yes
marlin attr set "~/marlin_demo/**/*.pdf" reviewed=yes
# (or with debug)
marlin --verbose tag "~/marlin_demo/Projects/Alpha/**/*" project/alpha
marlin --verbose attr set "~/marlin_demo/**/*.pdf" reviewed yes
# Output as JSON instead:
marlin --format=json attr set "~/marlin_demo/**/*.pdf" reviewed=yes
```
---
@@ -219,38 +216,34 @@ marlin --verbose attr set "~/marlin_demo/**/*.pdf" reviewed yes
# By tag or filename
marlin search alpha
# Combined terms (AND across path+attrs)
# Combined terms:
marlin search "reviewed AND pdf"
# Run a command on each hit
marlin search reviewed --exec "echo HIT → {}"
# If things arent matching, add --verbose to see the underlying FTS query:
marlin --verbose search "reviewed AND pdf"
# Run a command on each hit:
marlin search reviewed --exec 'echo HIT → {}'
```
> `{}` in `--exec` is replaced with each files path.
---
### 6Backup & restore
```bash
# Snapshot and store its name
# Snapshot
snap=$(marlin backup | awk '{print $NF}')
# Simulate data loss
# Simulate loss
rm ~/.local/share/marlin/index.db
# Restore instantly
# Restore
marlin restore "$snap"
# Verify your files still show up
# Verify
marlin search reviewed
```
> Backups live under `~/.local/share/marlin/backups` by default.
##### What you just exercised
| Command | Purpose |

View File

@@ -1,15 +1,36 @@
// src/cli.rs
use std::path::PathBuf;
use clap::{Parser, Subcommand};
pub mod link;
pub mod coll;
pub mod view;
pub mod state;
pub mod task;
pub mod remind;
pub mod annotate;
pub mod version;
pub mod event;
use clap::{Parser, Subcommand, ArgEnum, Args, CommandFactory};
use clap_complete::Shell;
/// Output format for commands.
#[derive(ArgEnum, Clone, Copy, Debug)]
pub enum Format {
Text,
Json,
}
/// Marlin metadata-driven file explorer (CLI utilities)
#[derive(Parser, Debug)]
#[command(author, version, about)]
#[command(author, version, about, propagate_version = true)]
pub struct Cli {
/// Enable debug logging and extra output
#[arg(long)]
pub verbose: bool,
/// Output format (text or JSON)
#[arg(long, default_value = "text", value_enum, global = true)]
pub format: Format,
#[command(subcommand)]
pub command: Commands,
}
@@ -21,12 +42,15 @@ pub enum Commands {
/// Scan one or more directories and populate the file index
Scan {
paths: Vec<PathBuf>,
/// Directories to scan (defaults to cwd)
paths: Vec<std::path::PathBuf>,
},
/// Tag files matching a glob pattern (hierarchical tags use `/`)
Tag {
/// Glob or path pattern
pattern: String,
/// Hierarchical tag name (`foo/bar`)
tag_path: String,
},
@@ -46,14 +70,57 @@ pub enum Commands {
/// Create a timestamped backup of the database
Backup,
/// Restore from a backup file (over-writes current DB)
/// Restore from a backup file (overwrites current DB)
Restore {
backup_path: PathBuf,
backup_path: std::path::PathBuf,
},
/// Generate shell completions (hidden)
#[command(hide = true)]
Completions {
#[arg(value_enum)]
shell: Shell,
},
/// File-to-file links
#[command(subcommand)]
Link { cmd: link::LinkCmd },
/// Collections (groups) of files
#[command(subcommand)]
Coll { cmd: coll::CollCmd },
/// Smart views (saved queries)
#[command(subcommand)]
View { cmd: view::ViewCmd },
/// Workflow states on files
#[command(subcommand)]
State { cmd: state::StateCmd },
/// TODO/tasks management
#[command(subcommand)]
Task { cmd: task::TaskCmd },
/// Reminders on files
#[command(subcommand)]
Remind { cmd: remind::RemindCmd },
/// File annotations and highlights
#[command(subcommand)]
Annotate { cmd: annotate::AnnotateCmd },
/// Version diffs
#[command(subcommand)]
Version { cmd: version::VersionCmd },
/// Calendar events & timelines
#[command(subcommand)]
Event { cmd: event::EventCmd },
}
#[derive(Subcommand, Debug)]
pub enum AttrCmd {
Set { pattern: String, key: String, value: String },
Ls { path: PathBuf },
Ls { path: std::path::PathBuf },
}

28
src/cli/annotate.rs Normal file
View File

@@ -0,0 +1,28 @@
// src/cli/annotate.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum AnnotateCmd {
Add (ArgsAdd),
List(ArgsList),
}
#[derive(Args, Debug)]
pub struct ArgsAdd {
pub file: String,
pub note: String,
#[arg(long)] pub range: Option<String>,
#[arg(long)] pub highlight: bool,
}
#[derive(Args, Debug)]
pub struct ArgsList { pub file_pattern: String }
pub fn run(cmd: &AnnotateCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
match cmd {
AnnotateCmd::Add(a) => todo!("annotate add {:?}", a),
AnnotateCmd::List(a) => todo!("annotate list {:?}", a),
}
}

26
src/cli/coll.rs Normal file
View File

@@ -0,0 +1,26 @@
// src/cli/coll.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum CollCmd {
Create(CreateArgs),
Add (AddArgs),
List (ListArgs),
}
#[derive(Args, Debug)]
pub struct CreateArgs { pub name: String }
#[derive(Args, Debug)]
pub struct AddArgs { pub name: String, pub file_pattern: String }
#[derive(Args, Debug)]
pub struct ListArgs { pub name: String }
pub fn run(cmd: &CollCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
match cmd {
CollCmd::Create(a) => todo!("coll create {:?}", a),
CollCmd::Add(a) => todo!("coll add {:?}", a),
CollCmd::List(a) => todo!("coll list {:?}", a),
}
}

81
src/cli/commands.yaml Normal file
View File

@@ -0,0 +1,81 @@
# cli/commands.yaml
# Philosophy: one canonical spec stops drift between docs & code.
link:
description: "Manage typed relationships between files"
actions:
add:
args: [from, to]
flags: ["--type"]
rm:
args: [from, to]
flags: ["--type"]
list:
args: [pattern]
flags: ["--direction", "--type"]
backlinks:
args: [pattern]
coll:
description: "Manage named collections of files"
actions:
create:
args: [name]
add:
args: [name, file_pattern]
list:
args: [name]
view:
description: "Save and use smart views (saved queries)"
actions:
save:
args: [view_name, query]
list: {}
exec:
args: [view_name]
state:
description: "Track workflow states on files"
actions:
set:
args: [file_pattern, new_state]
transitions-add:
args: [from_state, to_state]
log:
args: [file_pattern]
task:
description: "Extract TODOs and manage tasks"
actions:
scan:
args: [directory]
list:
flags: ["--due-today"]
remind:
description: "Attach reminders to files"
actions:
set:
args: [file_pattern, timestamp, message]
annotate:
description: "Add notes or highlights to files"
actions:
add:
args: [file, note]
flags: ["--range", "--highlight"]
list:
args: [file_pattern]
version:
description: "Versioning and diffs"
actions:
diff:
args: [file]
event:
description: "Link files to dates/events"
actions:
add:
args: [file, date, description]
timeline: {}

24
src/cli/event.rs Normal file
View File

@@ -0,0 +1,24 @@
// src/cli/event.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum EventCmd {
Add (ArgsAdd),
Timeline,
}
#[derive(Args, Debug)]
pub struct ArgsAdd {
pub file: String,
pub date: String,
pub description: String,
}
pub fn run(cmd: &EventCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
match cmd {
EventCmd::Add(a) => todo!("event add {:?}", a),
EventCmd::Timeline => todo!("event timeline"),
}
}

43
src/cli/link.rs Normal file
View File

@@ -0,0 +1,43 @@
// src/cli/link.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum LinkCmd {
Add(LinkArgs),
Rm (LinkArgs),
List(ListArgs),
Backlinks(BacklinksArgs),
}
#[derive(Args, Debug)]
pub struct LinkArgs {
pub from: String,
pub to: String,
#[arg(long)]
pub r#type: Option<String>,
}
#[derive(Args, Debug)]
pub struct ListArgs {
pub pattern: String,
#[arg(long)]
pub direction: Option<String>,
#[arg(long)]
pub r#type: Option<String>,
}
#[derive(Args, Debug)]
pub struct BacklinksArgs {
pub pattern: String,
}
pub fn run(cmd: &LinkCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
match cmd {
LinkCmd::Add(args) => todo!("link add {:?}", args),
LinkCmd::Rm(args) => todo!("link rm {:?}", args),
LinkCmd::List(args) => todo!("link list {:?}", args),
LinkCmd::Backlinks(args) => todo!("link backlinks {:?}", args),
}
}

22
src/cli/remind.rs Normal file
View File

@@ -0,0 +1,22 @@
// src/cli/remind.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum RemindCmd {
Set(ArgsSet),
}
#[derive(Args, Debug)]
pub struct ArgsSet {
pub file_pattern: String,
pub timestamp: String,
pub message: String,
}
pub fn run(cmd: &RemindCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
match cmd {
RemindCmd::Set(a) => todo!("remind set {:?}", a),
}
}

26
src/cli/state.rs Normal file
View File

@@ -0,0 +1,26 @@
// src/cli/state.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum StateCmd {
Set(ArgsSet),
TransitionsAdd(ArgsTrans),
Log(ArgsLog),
}
#[derive(Args, Debug)]
pub struct ArgsSet { pub file_pattern: String, pub new_state: String }
#[derive(Args, Debug)]
pub struct ArgsTrans { pub from_state: String, pub to_state: String }
#[derive(Args, Debug)]
pub struct ArgsLog { pub file_pattern: String }
pub fn run(cmd: &StateCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
match cmd {
StateCmd::Set(a) => todo!("state set {:?}", a),
StateCmd::TransitionsAdd(a)=> todo!("state transitions-add {:?}", a),
StateCmd::Log(a) => todo!("state log {:?}", a),
}
}

22
src/cli/task.rs Normal file
View File

@@ -0,0 +1,22 @@
// src/cli/task.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum TaskCmd {
Scan(ArgsScan),
List(ArgsList),
}
#[derive(Args, Debug)]
pub struct ArgsScan { pub directory: String }
#[derive(Args, Debug)]
pub struct ArgsList { #[arg(long)] pub due_today: bool }
pub fn run(cmd: &TaskCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
match cmd {
TaskCmd::Scan(a) => todo!("task scan {:?}", a),
TaskCmd::List(a) => todo!("task list {:?}", a),
}
}

18
src/cli/version.rs Normal file
View File

@@ -0,0 +1,18 @@
// src/cli/version.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum VersionCmd {
Diff(ArgsDiff),
}
#[derive(Args, Debug)]
pub struct ArgsDiff { pub file: String }
pub fn run(cmd: &VersionCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
match cmd {
VersionCmd::Diff(a) => todo!("version diff {:?}", a),
}
}

24
src/cli/view.rs Normal file
View File

@@ -0,0 +1,24 @@
// src/cli/view.rs
use clap::{Subcommand, Args};
use rusqlite::Connection;
use crate::cli::Format;
#[derive(Subcommand, Debug)]
pub enum ViewCmd {
Save(ArgsSave),
List,
Exec(ArgsExec),
}
#[derive(Args, Debug)]
pub struct ArgsSave { pub view_name: String, pub query: String }
#[derive(Args, Debug)]
pub struct ArgsExec { pub view_name: String }
pub fn run(cmd: &ViewCmd, conn: &mut Connection, format: Format) -> anyhow::Result<()> {
match cmd {
ViewCmd::Save(a) => todo!("view save {:?}", a),
ViewCmd::List => todo!("view list"),
ViewCmd::Exec(a)=> todo!("view exec {:?}", a),
}
}

View File

@@ -6,48 +6,55 @@ mod logging;
mod scan;
use anyhow::{Context, Result};
use clap::Parser;
use clap::{Parser, Subcommand};
use clap_complete::{generate, Shell};
use glob::Pattern;
use rusqlite::params;
use shellexpand;
use shlex;
use std::{env, path::PathBuf, process::Command};
use std::{env, io, path::PathBuf, process::Command};
use tracing::{debug, error, info};
use walkdir::WalkDir;
use cli::{AttrCmd, Cli, Commands};
use cli::{Cli, Commands, Format};
fn main() -> Result<()> {
// Parse CLI and bootstrap logging
let args = Cli::parse();
let mut args = Cli::parse();
if args.verbose {
// switch on debuglevel logs
env::set_var("RUST_LOG", "debug");
}
logging::init();
// Handle shell completions as a hidden command
if let Commands::Completions { shell } = args.command {
let mut cmd = Cli::command();
generate(shell, &mut cmd, "marlin", &mut io::stdout());
return Ok(());
}
let cfg = config::Config::load()?;
// Backup before any non-init, non-backup/restore command
if !matches!(args.command, Commands::Init | Commands::Backup | Commands::Restore { .. }) {
match db::backup(&cfg.db_path) {
match &args.command {
Commands::Init | Commands::Backup | Commands::Restore { .. } => {}
_ => match db::backup(&cfg.db_path) {
Ok(path) => info!("Pre-command auto-backup created at {}", path.display()),
Err(e) => error!("Failed to create pre-command auto-backup: {}", e),
}
Err(e) => error!("Failed to create pre-command auto-backup: {}", e),
},
}
// Open (and migrate) the DB
let mut conn = db::open(&cfg.db_path)?;
// Dispatch
match args.command {
Commands::Init => {
info!("Database initialised at {}", cfg.db_path.display());
}
Commands::Scan { paths } => {
// if none given, default to current dir
let scan_paths = if paths.is_empty() {
vec![env::current_dir()?]
vec![std::env::current_dir()?]
} else {
paths
};
@@ -55,38 +62,43 @@ fn main() -> Result<()> {
scan::scan_directory(&mut conn, &p)?;
}
}
Commands::Tag { pattern, tag_path } => {
apply_tag(&conn, &pattern, &tag_path)?;
}
Commands::Attr { action } => match action {
AttrCmd::Set { pattern, key, value } => {
cli::AttrCmd::Set { pattern, key, value } => {
attr_set(&conn, &pattern, &key, &value)?;
}
AttrCmd::Ls { path } => {
cli::AttrCmd::Ls { path } => {
attr_ls(&conn, &path)?;
}
},
Commands::Search { query, exec } => {
run_search(&conn, &query, exec)?;
}
Commands::Backup => {
let path = db::backup(&cfg.db_path)?;
println!("Backup created: {}", path.display());
}
Commands::Restore { backup_path } => {
drop(conn);
db::restore(&backup_path, &cfg.db_path)
.with_context(|| format!("Failed to restore DB from {}", backup_path.display()))?;
println!("Restored DB file from {}", backup_path.display());
println!("Restored DB from {}", backup_path.display());
db::open(&cfg.db_path)
.with_context(|| format!("Could not open restored DB at {}", cfg.db_path.display()))?;
info!("Successfully opened and processed restored database.");
info!("Successfully opened restored database.");
}
// new domains delegate to their run() functions
Commands::Link { cmd } => cli::link::run(&cmd, &mut conn, args.format)?,
Commands::Coll { cmd } => cli::coll::run(&cmd, &mut conn, args.format)?,
Commands::View { cmd } => cli::view::run(&cmd, &mut conn, args.format)?,
Commands::State { cmd } => cli::state::run(&cmd, &mut conn, args.format)?,
Commands::Task { cmd } => cli::task::run(&cmd, &mut conn, args.format)?,
Commands::Remind { cmd } => cli::remind::run(&cmd, &mut conn, args.format)?,
Commands::Annotate { cmd } => cli::annotate::run(&cmd, &mut conn, args.format)?,
Commands::Version { cmd } => cli::version::run(&cmd, &mut conn, args.format)?,
Commands::Event { cmd } => cli::event::run(&cmd, &mut conn, args.format)?,
}
Ok(())