Skip to content

Commit 5957646

Browse files
authored
feat: add touch command (#55)
1 parent 4a03357 commit 5957646

File tree

10 files changed

+237
-8
lines changed

10 files changed

+237
-8
lines changed

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,12 @@ storify rm path/to/dir -Rf # recursive + force (no confirmation)
169169
storify stat path/to/file # human-readable
170170
storify stat path/to/file --json # JSON output
171171
storify stat path/to/file --raw # raw key=value format
172-
```
172+
173+
# Create files (touch)
174+
storify touch path/to/file # create if missing; no change if exists
175+
storify touch -t path/to/file # truncate to 0 bytes if exists
176+
storify touch -c path/to/missing # do not create; succeed silently
177+
storify touch -p path/to/nested/file # create parents when applicable
173178

174179
## Command Reference
175180

@@ -178,12 +183,13 @@ storify stat path/to/file --raw # raw key=value format
178183
| Command | Description | Options |
179184
|---------|-------------|---------|
180185
| `ls` | List directory contents | `-L` (detailed), `-R` (recursive) |
181-
| `get` | Download files from remote | |
186+
| `get` | Download files from remote |
182187
| `put` | Upload files to remote | `-R` (recursive) |
183-
| `cp` | Copy files within storage | |
184-
| `mv` | Move/rename files within storage | |
188+
| `cp` | Copy files within storage |
189+
| `mv` | Move/rename files within storage |
185190
| `mkdir` | Create directories | `-p` (create parents) |
186-
| `cat` | Display file contents | |
191+
| `touch` | Create files |
192+
| `cat` | Display file contents |
187193
| `head` | Display beginning of file | `-n` (lines), `-c` (bytes), `-q` (quiet), `-v` (verbose) |
188194
| `tail` | Display end of file | `-n` (lines), `-c` (bytes), `-q` (quiet), `-v` (verbose) |
189195
| `grep` | Search for patterns in files | `-i` (case-insensitive), `-n` (line numbers) ,`-R` (recursive) |

src/cli/entry.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use super::{
88
prompts::Prompt,
99
storage::{
1010
self, CatArgs, CpArgs, DiffArgs, DuArgs, GetArgs, GrepArgs, HeadArgs, LsArgs, MkdirArgs,
11-
MvArgs, PutArgs, RmArgs, StatArgs, TailArgs, TreeArgs,
11+
MvArgs, PutArgs, RmArgs, StatArgs, TailArgs, TouchArgs, TreeArgs,
1212
},
1313
};
1414

@@ -89,6 +89,8 @@ pub enum Command {
8989
Tree(TreeArgs),
9090
/// Diff two files and print unified diff
9191
Diff(DiffArgs),
92+
/// Create empty files or update metadata (best-effort)
93+
Touch(TouchArgs),
9294
}
9395

9496
#[derive(Subcommand, Debug, Clone)]

src/cli/storage.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,25 @@ pub struct DiffArgs {
277277
pub force: bool,
278278
}
279279

280+
#[derive(ClapArgs, Debug, Clone)]
281+
pub struct TouchArgs {
282+
/// Remote path(s) to touch (create if not exists)
283+
#[arg(value_name = "PATH", value_parser = parse_validated_path)]
284+
pub paths: Vec<String>,
285+
286+
/// Do not create files; succeed silently if they do not exist
287+
#[arg(short = 'c', long = "no-create")]
288+
pub no_create: bool,
289+
290+
/// Truncate existing files to zero length (dangerous)
291+
#[arg(short = 't', long = "truncate")]
292+
pub truncate: bool,
293+
294+
/// Create parent directories when needed (filesystem providers)
295+
#[arg(short = 'p', long = "parents")]
296+
pub parents: bool,
297+
}
298+
280299
pub async fn execute(command: &Command, ctx: &CliContext) -> Result<()> {
281300
let config = ctx.storage_config()?;
282301
let client = StorageClient::new(config.clone()).await?;
@@ -418,6 +437,21 @@ pub async fn execute(command: &Command, ctx: &CliContext) -> Result<()> {
418437
)
419438
.await?;
420439
}
440+
Command::Touch(touch_args) => {
441+
if touch_args.paths.is_empty() {
442+
return Err(Error::InvalidArgument {
443+
message: "missing PATH".to_string(),
444+
});
445+
}
446+
client
447+
.touch_files(
448+
&touch_args.paths,
449+
touch_args.no_create,
450+
touch_args.truncate,
451+
touch_args.parents,
452+
)
453+
.await?;
454+
}
421455
Command::Config(_) => {
422456
unreachable!("Config commands are handled separately")
423457
}

src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ pub enum Error {
100100
source: Box<Error>,
101101
},
102102

103+
#[snafu(display("Failed to touch '{path}': {source}"))]
104+
TouchFailed { path: String, source: Box<Error> },
105+
103106
#[snafu(display("Invalid argument: {message}"))]
104107
InvalidArgument { message: String },
105108

src/storage.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,17 @@ use self::operations::list::OpenDalLister;
2020
use self::operations::mkdir::OpenDalMkdirer;
2121
use self::operations::mv::OpenDalMover;
2222
use self::operations::tail::OpenDalTailReader;
23+
use self::operations::touch::OpenDalToucher;
2324
use self::operations::tree::OpenDalTreer;
2425
use self::operations::upload::OpenDalUploader;
2526
use self::operations::usage::OpenDalUsageCalculator;
2627
use self::operations::{
2728
Cater, Copier, Deleter, Differ, Downloader, Greper, Header, Lister, Mkdirer, Mover, Stater,
28-
Tailer, Treer, Uploader, UsageCalculator,
29+
Tailer, Toucher, Treer, Uploader, UsageCalculator,
2930
};
3031
use crate::storage::utils::error::IntoStorifyError;
3132
use crate::wrap_err;
32-
use futures::stream::TryStreamExt;
33+
use futures::stream::{StreamExt, TryStreamExt};
3334

3435
/// Unified storage client using OpenDAL
3536
#[derive(Clone)]
@@ -750,4 +751,40 @@ impl StorageClient {
750751
}
751752
)
752753
}
754+
755+
pub async fn touch_files(
756+
&self,
757+
paths: &[String],
758+
no_create: bool,
759+
truncate: bool,
760+
parents: bool,
761+
) -> Result<()> {
762+
log::debug!(
763+
"touch_files provider={:?} paths_count={} no_create={} truncate={} parents={}",
764+
self.provider,
765+
paths.len(),
766+
no_create,
767+
truncate,
768+
parents
769+
);
770+
771+
let concurrency: usize = 8;
772+
futures::stream::iter(paths.iter().cloned())
773+
.map(|p| {
774+
let op = self.operator.clone();
775+
async move {
776+
let toucher = OpenDalToucher::new(op);
777+
toucher
778+
.touch(&p, no_create, truncate, parents)
779+
.await
780+
.map_err(|e| Error::TouchFailed {
781+
path: p.clone(),
782+
source: Box::new(e),
783+
})
784+
}
785+
})
786+
.buffer_unordered(concurrency)
787+
.try_for_each(|_| async { Ok(()) })
788+
.await
789+
}
753790
}

src/storage/operations/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod mkdir;
1212
pub mod mv;
1313
pub mod stat;
1414
pub mod tail;
15+
pub mod touch;
1516
pub mod tree;
1617
pub mod upload;
1718
pub mod usage;
@@ -29,6 +30,7 @@ pub use mkdir::Mkdirer;
2930
pub use mv::Mover;
3031
pub use stat::Stater;
3132
pub use tail::Tailer;
33+
pub use touch::Toucher;
3234
pub use tree::Treer;
3335
pub use upload::Uploader;
3436
pub use usage::UsageCalculator;

src/storage/operations/touch.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use crate::error::{Error, Result};
2+
use crate::storage::operations::Mkdirer;
3+
use crate::storage::operations::mkdir::OpenDalMkdirer;
4+
use opendal::{ErrorKind, Operator};
5+
6+
/// Trait for touching files in storage (create or truncate)
7+
pub trait Toucher {
8+
/// Ensure a file exists. Optionally truncate existing files.
9+
///
10+
/// - When `no_create` is true and the path does not exist, this is a no-op.
11+
/// - When `truncate` is true and the path exists as a file, it will be truncated to 0 bytes.
12+
/// - When `parents` is true, try to create parent directories when needed.
13+
async fn touch(&self, path: &str, no_create: bool, truncate: bool, parents: bool)
14+
-> Result<()>;
15+
}
16+
17+
pub struct OpenDalToucher {
18+
operator: Operator,
19+
}
20+
21+
impl OpenDalToucher {
22+
pub fn new(operator: Operator) -> Self {
23+
Self { operator }
24+
}
25+
26+
fn parent_dir_of(path: &str) -> Option<String> {
27+
let trimmed = path.trim_matches('/');
28+
if let Some(idx) = trimmed.rfind('/') {
29+
let (dir, _) = trimmed.split_at(idx);
30+
if dir.is_empty() {
31+
Some(String::new())
32+
} else {
33+
Some(format!("{}/", dir))
34+
}
35+
} else {
36+
None
37+
}
38+
}
39+
}
40+
41+
impl Toucher for OpenDalToucher {
42+
async fn touch(
43+
&self,
44+
path: &str,
45+
no_create: bool,
46+
truncate: bool,
47+
parents: bool,
48+
) -> Result<()> {
49+
if path.ends_with('/') {
50+
return Err(Error::InvalidArgument {
51+
message: "touch does not support directories; use mkdir".to_string(),
52+
});
53+
}
54+
55+
match self.operator.stat(path).await {
56+
Ok(meta) => {
57+
if meta.mode().is_dir() {
58+
return Err(Error::InvalidArgument {
59+
message: "Path is a directory; use mkdir".to_string(),
60+
});
61+
}
62+
63+
if truncate {
64+
let mut writer = self.operator.writer(path).await?;
65+
writer.close().await?;
66+
println!("Truncated: {}", path);
67+
}
68+
// else: exists and not truncating -> no-op
69+
Ok(())
70+
}
71+
Err(e) if e.kind() == ErrorKind::NotFound => {
72+
if no_create {
73+
// no-op when file is missing
74+
return Ok(());
75+
}
76+
77+
if parents
78+
&& let Some(parent) = Self::parent_dir_of(path)
79+
&& !parent.is_empty()
80+
{
81+
let mkdirer = OpenDalMkdirer::new(self.operator.clone());
82+
Mkdirer::mkdir(&mkdirer, &parent, true).await?;
83+
}
84+
let mut writer = self.operator.writer(path).await?;
85+
writer.close().await?;
86+
println!("Created: {}", path);
87+
Ok(())
88+
}
89+
Err(e) => Err(e.into()),
90+
}
91+
}
92+
}

tests/behavior/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ fn main() -> Result<()> {
3030
operations::stat::tests(&client, &mut tests);
3131
operations::tree::tests(&client, &mut tests);
3232
operations::diff::tests(&client, &mut tests);
33+
operations::touch::tests(&client, &mut tests);
3334

3435
let _ = tracing_subscriber::fmt()
3536
.pretty()

tests/behavior/operations/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod mkdir;
1111
pub mod mv;
1212
pub mod stat;
1313
pub mod tail;
14+
pub mod touch;
1415
pub mod tree;
1516
pub mod upload;
1617
pub mod usage;

tests/behavior/operations/touch.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use crate::*;
2+
use assert_cmd::prelude::*;
3+
use predicates::prelude::*;
4+
use storify::error::Result;
5+
use storify::storage::StorageClient;
6+
7+
pub fn tests(client: &StorageClient, tests: &mut Vec<Trial>) {
8+
tests.extend(async_trials!(
9+
client,
10+
test_touch_create_and_truncate,
11+
test_touch_no_create_is_noop,
12+
test_touch_parents
13+
));
14+
}
15+
16+
async fn test_touch_create_and_truncate(_client: StorageClient) -> Result<()> {
17+
let path = TEST_FIXTURE.new_file_path();
18+
19+
storify_cmd().arg("touch").arg(&path).assert().success();
20+
21+
storify_cmd()
22+
.arg("touch")
23+
.args(["-t", &path])
24+
.assert()
25+
.success();
26+
27+
Ok(())
28+
}
29+
30+
async fn test_touch_no_create_is_noop(_client: StorageClient) -> Result<()> {
31+
let path = TEST_FIXTURE.new_file_path();
32+
storify_cmd()
33+
.arg("touch")
34+
.args(["-c", &path])
35+
.assert()
36+
.success()
37+
.stdout(predicate::str::contains("Created:").not())
38+
.stdout(predicate::str::contains("Truncated:").not());
39+
Ok(())
40+
}
41+
42+
async fn test_touch_parents(_client: StorageClient) -> Result<()> {
43+
let dir = TEST_FIXTURE.new_dir_path();
44+
let nested = format!("{dir}a/b/c.txt");
45+
storify_cmd()
46+
.arg("touch")
47+
.args(["-p", &nested])
48+
.assert()
49+
.success();
50+
Ok(())
51+
}

0 commit comments

Comments
 (0)