Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
f045ac5844 | |||
f8ba7ee744
|
|||
4bde8ec240
|
|||
3fdd9f93a9
|
|||
139aa6d75e
|
|||
a71672823c
|
|||
c0bd8c0d36
|
|||
dadec333d4 | |||
18411bc52c
|
|||
6ce8f3e9b4 | |||
6e7166e6da
|
|||
53153659d9
|
|||
0019d45d9f
|
|||
07c88969bc | |||
ab6517e2c2
|
33
CHANGELOG.md
33
CHANGELOG.md
@@ -6,6 +6,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.0] - 2025-08-03
|
||||
|
||||
### Added
|
||||
- add open command
|
||||
- noil now handles open, and open in non-terminals via. /dev/tty
|
||||
- add file opener with chooser
|
||||
- update demo
|
||||
- add help text for preview
|
||||
|
||||
### Other
|
||||
- remove cockroach
|
||||
|
||||
## [0.0.5] - 2025-07-26
|
||||
|
||||
### Added
|
||||
- actually copy files
|
||||
|
||||
## [0.0.4] - 2025-07-26
|
||||
|
||||
### Added
|
||||
- can now do file operations
|
||||
|
||||
### Other
|
||||
- test
|
||||
|
||||
- replace url
|
||||
|
||||
|
||||
## [0.0.3] - 2025-07-26
|
||||
|
||||
### Added
|
||||
- with auto publish
|
||||
|
||||
## [0.0.2] - 2025-07-26
|
||||
|
||||
### Docs
|
||||
|
93
Cargo.lock
generated
93
Cargo.lock
generated
@@ -257,12 +257,44 @@ version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.3"
|
||||
@@ -345,6 +377,16 @@ version = "0.2.174"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.13"
|
||||
@@ -389,7 +431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "noil"
|
||||
version = "0.0.1"
|
||||
version = "0.0.5"
|
||||
dependencies = [
|
||||
"ansi_term",
|
||||
"anyhow",
|
||||
@@ -399,6 +441,7 @@ dependencies = [
|
||||
"ignore",
|
||||
"pretty_assertions",
|
||||
"rand",
|
||||
"shellexpand",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -436,6 +479,12 @@ version = "1.70.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "overload"
|
||||
version = "0.1.1"
|
||||
@@ -540,7 +589,7 @@ version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.3.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -552,6 +601,17 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.9"
|
||||
@@ -619,6 +679,15 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellexpand"
|
||||
version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -673,6 +742,26 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
|
@@ -3,7 +3,7 @@ members = ["crates/*"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.2"
|
||||
version = "0.1.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
noil = { path = "crates/noil" }
|
||||
|
77
README.md
77
README.md
@@ -1,8 +1,11 @@
|
||||
# noil
|
||||
|
||||
**noil** is a structured, text-buffer-based file operation tool – think of it like [`oil.nvim`](https://github.com/stevearc/oil.nvim), but for any editor, terminal, or automated process.
|
||||
**noil** is a structured, text-buffer-based file operation tool – think of it
|
||||
like [`oil.nvim`](https://github.com/stevearc/oil.nvim), but for any editor,
|
||||
terminal, or automated process.
|
||||
|
||||
Edit filesystem operations like it's plain text, and then apply them in a controlled, explicit way.
|
||||
Edit filesystem operations like it's plain text, and then apply them in a
|
||||
controlled, explicit way.
|
||||
|
||||

|
||||
|
||||
@@ -10,17 +13,19 @@ Edit filesystem operations like it's plain text, and then apply them in a contro
|
||||
|
||||
## Interactive Mode (TBA)
|
||||
|
||||
I am planning an interactive TUI mode, where you don't have to care about tags, like in `oil`. For now the normal editor is quite useful though, and allows all types of editors to easily move, edit files and so on.
|
||||
I am planning an interactive TUI mode, where you don't have to care about tags,
|
||||
like in `oil`. For now the normal editor is quite useful though, and allows all
|
||||
types of editors to easily move, edit files and so on.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
* Edit your file tree like a normal buffer
|
||||
* Preview, format, and apply changes
|
||||
* Integrates with `$EDITOR`
|
||||
* CLI first, editor agnostic
|
||||
* No surprises: nothing is applied until you say so
|
||||
- Edit your file tree like a normal buffer
|
||||
- Preview, format, and apply changes
|
||||
- Integrates with `$EDITOR`
|
||||
- CLI first, editor agnostic
|
||||
- No surprises: nothing is applied until you say so
|
||||
|
||||
---
|
||||
|
||||
@@ -42,7 +47,7 @@ cat something.noil | noil fmt > something.noil
|
||||
cat something.noil | noil apply
|
||||
```
|
||||
|
||||
noil will always ask you if you want to apply your changes before doing any operations.
|
||||
noil will ask you if you want to apply your changes before doing any operations.
|
||||
|
||||
---
|
||||
|
||||
@@ -56,13 +61,14 @@ Each line follows this format:
|
||||
|
||||
### Supported operations:
|
||||
|
||||
| Operation | Meaning | Tag Required? |
|
||||
| --------: | --------------------------------- | ------------- |
|
||||
| `ADD` | Add new file | ❌ No |
|
||||
| `COPY` | Copy file with given tag | ✅ Yes |
|
||||
| `DELETE` | Delete file with given tag | ✅ Yes |
|
||||
| `MOVE` | Move file with given tag | ✅ Yes |
|
||||
| *(blank)* | Reference existing file (default) | ✅ Yes |
|
||||
| Operation | Meaning | Tag Required? |
|
||||
| --------: | ------------------------------------------------------ | ------------- |
|
||||
| `ADD` | Add new file | ❌ No |
|
||||
| `COPY` | Copy file with given tag | ✅ Yes |
|
||||
| `DELETE` | Delete file with given tag | ✅ Yes |
|
||||
| `MOVE` | Move file with given tag | ✅ Yes |
|
||||
| `OPEN` | Open a file with a given tag (requires --chooser-file) | ❌ No |
|
||||
| _(blank)_ | Reference existing file (default) | ✅ Yes |
|
||||
|
||||
---
|
||||
|
||||
@@ -73,9 +79,11 @@ Each line follows this format:
|
||||
COPY abc : /tmp/nginx-copy
|
||||
DELETE 123 : /etc/nginx
|
||||
ADD : /new/file.txt
|
||||
OPEN : /new/file.txt
|
||||
```
|
||||
|
||||
You can use short, unique tags (like `abc`, `ng1`, etc.) to refer to files. `noil` will generate these tags when you run `noil .`.
|
||||
You can use short, unique tags (like `abc`, `ng1`, etc.) to refer to files.
|
||||
`noil` will generate these tags when you run `noil .`.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,9 +95,11 @@ Want to clean up alignment and spacing?
|
||||
cat my-buffer.noil | noil fmt
|
||||
```
|
||||
|
||||
Or automatically format inside your editor with the following config for [Helix](https://helix-editor.com):
|
||||
Or automatically format inside your editor with the following config for
|
||||
[Helix](https://helix-editor.com):
|
||||
|
||||
```toml
|
||||
# .config/helix/languages.toml
|
||||
[[language]]
|
||||
name = "noil"
|
||||
scope = "source.noil"
|
||||
@@ -101,9 +111,31 @@ formatter = { command = "noil", args = ["fmt"] }
|
||||
|
||||
[[grammar]]
|
||||
name = "noil"
|
||||
source = { git = "https://git.front.kjuulh.io/kjuulh/tree-sitter-noil.git", rev = "2f295629439881d0b9e89108a1296881d0daf7b9" }
|
||||
source = { git = "https://git.kjuulh.io/kjuulh/tree-sitter-noil.git", rev = "2f295629439881d0b9e89108a1296881d0daf7b9" }
|
||||
|
||||
# .config/helix/config.toml
|
||||
# Optional extra command Space + o will open noil allowing edits and the OPEN command
|
||||
[keys.normal.space]
|
||||
o = [
|
||||
":sh rm -f /tmp/unique-file-kjuulh",
|
||||
# DISCLAIMER: Until noil has a proper interactive mode, we cannot ask for confirmation, as such we always commit changes, you don't get to have a preview unlike the normal cli option
|
||||
":insert-output noil edit '%{buffer_name}' --chooser-file=/tmp/unique-file-kjuulh --commit --quiet < /dev/tty",
|
||||
":insert-output echo \"x1b[?1049h\" > /dev/tty",
|
||||
":open %sh{cat /tmp/unique-file-kjuulh}",
|
||||
":redraw",
|
||||
]
|
||||
```
|
||||
|
||||
### Edit options
|
||||
|
||||
When using `noil edit .` a few additional options are available
|
||||
|
||||
- `--chooser-file`: A chooser file is a newline delimited file where each line
|
||||
corresponds to a relative file to be opened or manipulated by the user. Only
|
||||
items with `OPEN` command will be added to the file
|
||||
- `--commit`: commit files without asking for confirmation
|
||||
- `--quiet`: don't print results
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Safety First
|
||||
@@ -123,7 +155,9 @@ You will be prompted before anything is modified.
|
||||
|
||||
## 🧠 Philosophy
|
||||
|
||||
noil gives you full control over file operations in a composable and editor-friendly way. Think Git index, but for actual file moves and deletions — human-editable, patchable, and grep-able.
|
||||
noil gives you full control over file operations in a composable and
|
||||
editor-friendly way. Think Git index, but for actual file moves and deletions —
|
||||
human-editable, patchable, and grep-able.
|
||||
|
||||
---
|
||||
|
||||
@@ -135,11 +169,10 @@ noil gives you full control over file operations in a composable and editor-frie
|
||||
cargo install noil
|
||||
```
|
||||
|
||||
|
||||
**Build from source**:
|
||||
|
||||
```bash
|
||||
cargo install --git https://git.front.kjuulh.io/kjuulh/noil.git
|
||||
cargo install --git https://git.kjuulh.io/kjuulh/noil.git
|
||||
```
|
||||
|
||||
Or clone locally and run with `cargo run`.
|
||||
|
BIN
assets/demo.gif
BIN
assets/demo.gif
Binary file not shown.
Before Width: | Height: | Size: 631 KiB After Width: | Height: | Size: 1.4 MiB |
@@ -5,7 +5,7 @@ readme = "../../README.md"
|
||||
license = "MIT"
|
||||
description = "file explorer using text buffers"
|
||||
authors = ["kjuulh <contact@kasperhermansen.com>"]
|
||||
repository = "https://git.front.kjuulh.io/kjuulh/noil"
|
||||
repository = "https://git.kjuulh.io/kjuulh/noil"
|
||||
|
||||
version.workspace = true
|
||||
|
||||
@@ -21,6 +21,7 @@ ignore = "0.4.23"
|
||||
blake3 = "1.8.2"
|
||||
rand = "0.9.2"
|
||||
ansi_term = "0.12.1"
|
||||
shellexpand = "3.1.1"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4.1"
|
||||
|
@@ -1,9 +1,20 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::commit::write_changes;
|
||||
use crate::{
|
||||
cli::edit::{ApplyOptions, apply},
|
||||
commit::{Action, print_changes},
|
||||
};
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
pub struct ApplyCommand {}
|
||||
pub struct ApplyCommand {
|
||||
#[arg(long = "commit")]
|
||||
commit: bool,
|
||||
|
||||
#[arg(long = "chooser-file", env = "NOIL_CHOOSER_FILE")]
|
||||
chooser_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ApplyCommand {
|
||||
pub async fn execute(&self) -> anyhow::Result<()> {
|
||||
@@ -14,8 +25,35 @@ impl ApplyCommand {
|
||||
|
||||
let input = String::from_utf8_lossy(&buffer);
|
||||
|
||||
write_changes(&input).await?;
|
||||
if !self.commit {
|
||||
let action = print_changes(&input, !self.commit).await?;
|
||||
let res = match action {
|
||||
Action::Quit => Ok(()),
|
||||
Action::Apply { original } => {
|
||||
apply(
|
||||
&original,
|
||||
ApplyOptions {
|
||||
chooser_file: self.chooser_file.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
Action::Edit => todo!(),
|
||||
};
|
||||
|
||||
Ok(())
|
||||
eprintln!("\nin preview mode: add (--commit) to perform actions");
|
||||
|
||||
res
|
||||
} else {
|
||||
apply(
|
||||
&input,
|
||||
ApplyOptions {
|
||||
chooser_file: self.chooser_file.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,15 +1,41 @@
|
||||
use std::{env::temp_dir, path::PathBuf};
|
||||
use std::{
|
||||
env::temp_dir,
|
||||
io::{IsTerminal, Write},
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use ansi_term::Color;
|
||||
use anyhow::{Context, bail};
|
||||
use clap::Parser;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::{
|
||||
fs::OpenOptions,
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||
};
|
||||
|
||||
use crate::{commit::write_changes, encode_rand, output::get_outputs};
|
||||
use crate::{
|
||||
commit::{Action, print_changes},
|
||||
encode_rand,
|
||||
models::Operation,
|
||||
output::get_outputs,
|
||||
parse,
|
||||
};
|
||||
|
||||
const PREVIEW: bool = false;
|
||||
|
||||
#[derive(Parser)]
|
||||
pub struct EditCommand {
|
||||
#[arg()]
|
||||
path: PathBuf,
|
||||
|
||||
#[arg(long = "chooser-file", env = "NOIL_CHOOSER_FILE")]
|
||||
chooser_file: Option<PathBuf>,
|
||||
|
||||
#[arg(long = "commit")]
|
||||
commit: bool,
|
||||
|
||||
#[arg(long = "quiet")]
|
||||
quiet: bool,
|
||||
}
|
||||
|
||||
impl EditCommand {
|
||||
@@ -36,27 +62,328 @@ impl EditCommand {
|
||||
.await
|
||||
.context("create temp file for noil")?;
|
||||
|
||||
let output = get_outputs(&self.path, true).await?;
|
||||
file.write_all(output.as_bytes()).await?;
|
||||
file.flush().await?;
|
||||
let output = get_outputs(&self.get_path().await.context("get path")?, true)
|
||||
.await
|
||||
.context("get output")?;
|
||||
file.write_all(output.as_bytes())
|
||||
.await
|
||||
.context("write contents for edit")?;
|
||||
file.flush().await.context("flush contents for edit")?;
|
||||
|
||||
let editor = std::env::var("EDITOR").context("EDITOR not found in env")?;
|
||||
|
||||
let mut cmd = tokio::process::Command::new(editor.trim());
|
||||
cmd.arg(&file_path);
|
||||
loop {
|
||||
let mut cmd = tokio::process::Command::new(editor.trim());
|
||||
cmd.arg(&file_path);
|
||||
|
||||
let mut process = cmd.spawn()?;
|
||||
let status = process.wait().await.context("editor closed prematurely")?;
|
||||
if !status.success() {
|
||||
let code = status.code().unwrap_or(-1);
|
||||
anyhow::bail!("editor exited: {code}");
|
||||
if !std::io::stdout().is_terminal() {
|
||||
let tty = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open("/dev/tty")
|
||||
.await
|
||||
.context("open tty")?;
|
||||
let tty_in = tty.try_clone().await.context("clone ttyin")?;
|
||||
let tty_out = tty.try_clone().await.context("clone ttyout")?;
|
||||
|
||||
cmd.stdin(Stdio::from(tty_in.into_std().await))
|
||||
.stdout(Stdio::from(tty_out.into_std().await))
|
||||
.stderr(Stdio::from(tty.into_std().await));
|
||||
}
|
||||
|
||||
let mut process = cmd.spawn().context("command not found")?;
|
||||
let status = process.wait().await.context("editor closed prematurely")?;
|
||||
if !status.success() {
|
||||
let code = status.code().unwrap_or(-1);
|
||||
anyhow::bail!("editor exited: {code}");
|
||||
}
|
||||
|
||||
let noil_content = tokio::fs::read_to_string(&file_path)
|
||||
.await
|
||||
.context("read noil file")?;
|
||||
|
||||
let res = if !self.commit {
|
||||
print_changes(&noil_content, PREVIEW).await
|
||||
} else {
|
||||
Ok(Action::Apply {
|
||||
original: noil_content,
|
||||
})
|
||||
};
|
||||
|
||||
let action = match res {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"Invalid operation\n{}\n\nreverting to edit on any key press: ",
|
||||
Color::Red.normal().paint(format!("{e:?}"))
|
||||
);
|
||||
|
||||
wait_user().await.context("user finished prematurely")?;
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match action {
|
||||
Action::Quit => return Ok(()),
|
||||
Action::Apply { original } => {
|
||||
return apply(
|
||||
&original,
|
||||
ApplyOptions {
|
||||
chooser_file: self.chooser_file.clone(),
|
||||
quiet: self.quiet,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Action::Edit => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_path(&self) -> anyhow::Result<PathBuf> {
|
||||
let path_str = self.path.display().to_string();
|
||||
let expanded_path = shellexpand::full(&path_str)?;
|
||||
let path = PathBuf::from(expanded_path.to_string());
|
||||
|
||||
if !path.exists() {
|
||||
anyhow::bail!("path: {} does not exist", self.path.display());
|
||||
}
|
||||
|
||||
let noil_content = tokio::fs::read_to_string(&file_path)
|
||||
.await
|
||||
.context("read noil file")?;
|
||||
if path.is_file() {
|
||||
return path
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or(anyhow::anyhow!("parent doesn't exist for file"));
|
||||
}
|
||||
|
||||
write_changes(&noil_content).await?;
|
||||
Ok(())
|
||||
Ok(path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_user() -> Result<(), anyhow::Error> {
|
||||
let mut stderr = std::io::stderr();
|
||||
stderr.flush()?;
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut input_buf = String::new();
|
||||
reader
|
||||
.read_line(&mut input_buf)
|
||||
.await
|
||||
.context("failed to read stdin")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct ApplyOptions {
|
||||
pub chooser_file: Option<PathBuf>,
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
/// the philosphy behind apply is that we try unlike normal file system operations to be idempotent.
|
||||
/// This is mainly for 2 reasons.
|
||||
///
|
||||
/// 1. A lot of operations are processed, stopping in the middle because of an error would ruing your previous procedure that you now have to go back and fix
|
||||
/// 2. A .noil recipe can be rerun, having small issues disrupt the work would be counterproductive, as the .noil language is not powerful enough to handle the flexibility required for file checking
|
||||
///
|
||||
/// All in all apply is mostly idempotent, and won't override files, it tries to be as non destructive as possible. For example move will only throw a warning if the source file doesn't exists, but the destination does
|
||||
pub async fn apply(input: &str, options: ApplyOptions) -> anyhow::Result<()> {
|
||||
if !options.quiet {
|
||||
eprintln!("applying changes");
|
||||
}
|
||||
|
||||
let noil_index = parse::parse_input(input).context("parse input")?;
|
||||
|
||||
let mut open_files = Vec::new();
|
||||
|
||||
for file in &noil_index.files {
|
||||
let path = &file.path;
|
||||
match &file.entry.operation {
|
||||
Operation::Existing { .. } => {
|
||||
// Noop
|
||||
}
|
||||
Operation::Open { .. } => {
|
||||
if path.to_string_lossy().ends_with("/") {
|
||||
// We can't open directories, so they're skipped
|
||||
continue;
|
||||
}
|
||||
|
||||
open_files.push(path);
|
||||
}
|
||||
Operation::Add => {
|
||||
tracing::debug!("creating file");
|
||||
|
||||
if path.exists() {
|
||||
tracing::warn!("path already exists");
|
||||
continue;
|
||||
}
|
||||
|
||||
// is dir
|
||||
if path.to_string_lossy().ends_with("/") {
|
||||
tokio::fs::create_dir_all(&path)
|
||||
.await
|
||||
.context("add directory")?;
|
||||
tracing::info!("added directory");
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(&parent)
|
||||
.await
|
||||
.context("create parent dir for add file")?;
|
||||
}
|
||||
|
||||
tokio::fs::File::create(&path).await.context("add file")?;
|
||||
|
||||
tracing::info!("added file");
|
||||
}
|
||||
Operation::Copy { index } => {
|
||||
tracing::debug!("copying file");
|
||||
|
||||
let existing = noil_index.get_existing(index).ok_or(anyhow::anyhow!(
|
||||
"entry with index: '{}' does not exist for copy",
|
||||
index
|
||||
))?;
|
||||
if !existing.path.exists() {
|
||||
bail!("existing does not exist for copy")
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
tracing::warn!("path already exists, cannot copy");
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(&parent)
|
||||
.await
|
||||
.context("create parent dir for copy")?;
|
||||
}
|
||||
|
||||
if existing.path.is_dir() {
|
||||
tracing::debug!("copying dir");
|
||||
copy(&existing.path, path).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
tokio::fs::copy(&existing.path, &path)
|
||||
.await
|
||||
.context("copy file for copy")?;
|
||||
}
|
||||
Operation::Delete { .. } => {
|
||||
tracing::debug!("deleting file");
|
||||
|
||||
if !path.exists() {
|
||||
tracing::warn!("path doesn't exist");
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
tokio::fs::remove_dir_all(&path)
|
||||
.await
|
||||
.context("remove path for delete")?;
|
||||
continue;
|
||||
}
|
||||
|
||||
tokio::fs::remove_file(&path)
|
||||
.await
|
||||
.context("remove file for delete")?
|
||||
}
|
||||
Operation::Move { index } => {
|
||||
tracing::debug!("moving file");
|
||||
|
||||
let existing = noil_index.get_existing(index);
|
||||
|
||||
if existing.is_none() {
|
||||
// If the destination exists, but the existing one doesn't we assume it has already been moved
|
||||
if path.exists() {
|
||||
tracing::warn!("destination file looks to already have been moved");
|
||||
continue;
|
||||
}
|
||||
|
||||
anyhow::bail!("neither existing, or destination exists for move");
|
||||
}
|
||||
let existing = existing.unwrap();
|
||||
|
||||
if path.exists() {
|
||||
anyhow::bail!("destination already exists cannot move");
|
||||
}
|
||||
|
||||
tokio::fs::rename(&existing.path, path)
|
||||
.await
|
||||
.context("move path")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(chooser_file) = &options.chooser_file {
|
||||
tracing::debug!("creating chooser file");
|
||||
if let Some(parent) = chooser_file.parent()
|
||||
&& !chooser_file.exists()
|
||||
{
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("parent dir for chooser file")?;
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::create(chooser_file)
|
||||
.await
|
||||
.context("create new chooser file")?;
|
||||
|
||||
let open_files = open_files
|
||||
.iter()
|
||||
.map(|i| i.display().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
file.write_all(open_files.join("\n").as_bytes())
|
||||
.await
|
||||
.context("write chosen files")?;
|
||||
file.flush().await.context("flush chosen file")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy(source: &Path, dest: &Path) -> anyhow::Result<()> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
for entry in walkdir::WalkDir::new(source) {
|
||||
let entry = entry?;
|
||||
|
||||
tracing::debug!("copying path: {}", entry.path().display());
|
||||
|
||||
paths.push(entry.path().strip_prefix(source)?.to_path_buf());
|
||||
}
|
||||
|
||||
for path in paths {
|
||||
let source = source.join(&path);
|
||||
let dest = dest.join(&path);
|
||||
|
||||
copy_path(&source, &dest).await.context(anyhow::anyhow!(
|
||||
"copy path: (src: {}, dest: {})",
|
||||
source.display(),
|
||||
dest.display()
|
||||
))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn copy_path(src: &Path, dest: &Path) -> anyhow::Result<()> {
|
||||
if let Some(parent) = dest.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("copy dir create parent dir")?;
|
||||
}
|
||||
|
||||
if src.is_dir() {
|
||||
tracing::info!("copying dir: {}", dest.display());
|
||||
tokio::fs::create_dir_all(&dest).await.context("copy dir")?;
|
||||
}
|
||||
|
||||
if src.is_file() {
|
||||
tracing::info!("copying file: {}", dest.display());
|
||||
tokio::fs::copy(&src, &dest).await.context("copy file")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@@ -1,11 +1,21 @@
|
||||
use std::path::Path;
|
||||
|
||||
use ansi_term::Color;
|
||||
use anyhow::Context;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
use crate::{models::Operation, parse::parse};
|
||||
use std::io::Write;
|
||||
|
||||
pub async fn write_changes(input: &str) -> anyhow::Result<()> {
|
||||
let noil_index = parse(input).context("parse input")?;
|
||||
use crate::{models::Operation, parse::parse_input};
|
||||
|
||||
pub enum Action {
|
||||
Quit,
|
||||
Apply { original: String },
|
||||
Edit,
|
||||
}
|
||||
|
||||
pub async fn print_changes(input: &str, preview: bool) -> anyhow::Result<Action> {
|
||||
let noil_index = parse_input(input).context("parse input")?;
|
||||
|
||||
fn print_op(key: &str, index: Option<&str>, path: Option<&Path>) {
|
||||
match index {
|
||||
@@ -54,8 +64,52 @@ pub async fn write_changes(input: &str) -> anyhow::Result<()> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
print!("\nApply changes? (y/N): ");
|
||||
println!();
|
||||
|
||||
Ok(())
|
||||
if preview {
|
||||
return Ok(Action::Quit);
|
||||
}
|
||||
|
||||
eprint!("\nApply changes? (y (yes) / n (abort) / E (edit)): ");
|
||||
let mut stderr = std::io::stderr();
|
||||
stderr.flush()?;
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut input_buf = String::new();
|
||||
|
||||
reader.read_line(&mut input_buf).await?;
|
||||
let trimmed = input_buf.trim().to_lowercase();
|
||||
|
||||
match trimmed.as_str() {
|
||||
"y" => {
|
||||
println!("Confirmed.");
|
||||
|
||||
Ok(Action::Apply {
|
||||
original: input.to_string(),
|
||||
})
|
||||
}
|
||||
"n" => {
|
||||
println!("Aborted.");
|
||||
Ok(Action::Quit)
|
||||
}
|
||||
"e" | "" => {
|
||||
println!("Edit");
|
||||
|
||||
Ok(Action::Edit)
|
||||
}
|
||||
_ => {
|
||||
println!("Invalid input: {}", Color::Red.normal().paint(trimmed));
|
||||
|
||||
eprint!("press enter to edit: ");
|
||||
let mut stderr = std::io::stderr();
|
||||
stderr.flush()?;
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
let mut input_buf = String::new();
|
||||
reader.read_line(&mut input_buf).await?;
|
||||
|
||||
Ok(Action::Edit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,10 +4,10 @@ use anyhow::Context;
|
||||
|
||||
use crate::models;
|
||||
|
||||
use super::parse::parse;
|
||||
use super::parse::parse_input;
|
||||
|
||||
pub(crate) fn format(input: &str) -> anyhow::Result<String> {
|
||||
let noil_index = parse(input).context("parse input")?;
|
||||
let noil_index = parse_input(input).context("parse input")?;
|
||||
|
||||
let max_op_len = noil_index
|
||||
.files
|
||||
@@ -23,6 +23,9 @@ pub(crate) fn format(input: &str) -> anyhow::Result<String> {
|
||||
| models::Operation::Delete { index }
|
||||
| models::Operation::Move { index }
|
||||
| models::Operation::Existing { index } => index.len(),
|
||||
models::Operation::Open { index } => {
|
||||
index.as_ref().map(|i| i.len()).unwrap_or_default()
|
||||
}
|
||||
models::Operation::Add => 0,
|
||||
})
|
||||
.max()
|
||||
@@ -53,6 +56,7 @@ pub(crate) fn format(input: &str) -> anyhow::Result<String> {
|
||||
| models::Operation::Delete { index }
|
||||
| models::Operation::Move { index }
|
||||
| models::Operation::Existing { index } => Some(index),
|
||||
models::Operation::Open { index } => index,
|
||||
models::Operation::Add => None,
|
||||
};
|
||||
|
||||
|
@@ -7,6 +7,15 @@ pub struct Buffer {
|
||||
pub(crate) files: Vec<File>,
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
pub fn get_existing(&self, index: &str) -> Option<&File> {
|
||||
self.files.iter().find(|f| match &f.entry.operation {
|
||||
Operation::Existing { index: idx } => idx == index,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
pub struct File {
|
||||
pub(crate) path: PathBuf,
|
||||
@@ -26,6 +35,7 @@ pub enum Operation {
|
||||
Copy { index: String },
|
||||
Delete { index: String },
|
||||
Move { index: String },
|
||||
Open { index: Option<String> },
|
||||
}
|
||||
|
||||
impl Display for Operation {
|
||||
@@ -36,6 +46,7 @@ impl Display for Operation {
|
||||
Operation::Copy { .. } => "COPY",
|
||||
Operation::Delete { .. } => "DELETE",
|
||||
Operation::Move { .. } => "MOVE",
|
||||
Operation::Open { .. } => "OPEN",
|
||||
};
|
||||
|
||||
f.write_str(op)
|
||||
@@ -77,6 +88,16 @@ impl FileEntry {
|
||||
"D" | "DEL" | "DELETE" if first != last => Operation::Delete { index },
|
||||
// MOVE:
|
||||
"M" | "MV" | "MOVE" | "RENAME" if first != last => Operation::Move { index },
|
||||
"O" | "OPEN" => Operation::Open {
|
||||
index: {
|
||||
// if LAST == is the Operation, we set the index to empty, if the index is missing we set it to None
|
||||
if index.is_empty() || index.chars().any(|c| c.is_uppercase()) {
|
||||
None
|
||||
} else {
|
||||
Some(index)
|
||||
}
|
||||
},
|
||||
},
|
||||
o => {
|
||||
anyhow::bail!("operation: {} is not supported", o);
|
||||
}
|
||||
@@ -100,7 +121,7 @@ abc : /var/my
|
||||
ecd : /var/my/path
|
||||
"#;
|
||||
|
||||
let output = parse::parse(input)?;
|
||||
let output = parse::parse_input(input)?;
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
Buffer {
|
||||
@@ -140,7 +161,7 @@ A : /var/my/path/new-path
|
||||
ADD : /var/my/path/new-long-path
|
||||
"#;
|
||||
|
||||
let output = parse::parse(input)?;
|
||||
let output = parse::parse_input(input)?;
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
Buffer {
|
||||
@@ -185,6 +206,60 @@ ADD : /var/my/path/new-long-path
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_parse_item_open_operation() -> anyhow::Result<()> {
|
||||
let input = r#"
|
||||
O abc : /var/my
|
||||
OPEN ecd : /var/my/path
|
||||
O : /var/my/path/new-path
|
||||
OPEN : /var/my/path/new-long-path
|
||||
"#;
|
||||
|
||||
let output = parse::parse_input(input)?;
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
Buffer {
|
||||
files: vec![
|
||||
File {
|
||||
path: "/var/my".into(),
|
||||
entry: FileEntry {
|
||||
raw_op: Some("O".into()),
|
||||
operation: Operation::Open {
|
||||
index: Some("abc".into()),
|
||||
}
|
||||
},
|
||||
},
|
||||
File {
|
||||
path: "/var/my/path".into(),
|
||||
entry: FileEntry {
|
||||
raw_op: Some("OPEN".into()),
|
||||
operation: Operation::Open {
|
||||
index: Some("ecd".into())
|
||||
}
|
||||
},
|
||||
},
|
||||
File {
|
||||
path: "/var/my/path/new-path".into(),
|
||||
entry: FileEntry {
|
||||
raw_op: Some("O".into()),
|
||||
operation: Operation::Open { index: None },
|
||||
},
|
||||
},
|
||||
File {
|
||||
path: "/var/my/path/new-long-path".into(),
|
||||
entry: FileEntry {
|
||||
raw_op: Some("OPEN".into()),
|
||||
operation: Operation::Open { index: None },
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
output
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_parse_item_copy_operation() -> anyhow::Result<()> {
|
||||
let input = r#"
|
||||
@@ -194,7 +269,7 @@ C abc : /var/my/path/copy-into
|
||||
COPY ecd : /var/my/path/copy-into-long
|
||||
"#;
|
||||
|
||||
let output = parse::parse(input)?;
|
||||
let output = parse::parse_input(input)?;
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
Buffer {
|
||||
@@ -250,7 +325,7 @@ DEL ecd : /var/my/path
|
||||
DELETE ecd : /var/my/path
|
||||
"#;
|
||||
|
||||
let output = parse::parse(input)?;
|
||||
let output = parse::parse_input(input)?;
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
Buffer {
|
||||
@@ -301,7 +376,7 @@ MOVE ecd : /var/my/some-different-place
|
||||
RENAME ecd : /var/my/some-different-place
|
||||
"#;
|
||||
|
||||
let output = parse::parse(input)?;
|
||||
let output = parse::parse_input(input)?;
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
Buffer {
|
||||
|
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::models;
|
||||
|
||||
pub(crate) fn parse(input: &str) -> anyhow::Result<models::Buffer> {
|
||||
pub(crate) fn parse_input(input: &str) -> anyhow::Result<models::Buffer> {
|
||||
let mut files = Vec::default();
|
||||
// We are keeping parsing simple. For each line take any non empty lines, the first part should be an index. This is where the magic happens, if it contains special tokens handle accordingly, the path always comes after a :.
|
||||
for line in input.lines() {
|
||||
|
@@ -1,10 +1,12 @@
|
||||
# yaml-language-server: $schema=https://git.front.kjuulh.io/kjuulh/cuddle/raw/branch/main/schemas/base.json
|
||||
# yaml-language-server: $schema=https://git.kjuulh.io/kjuulh/cuddle/raw/branch/main/schemas/base.json
|
||||
|
||||
base: "git@git.front.kjuulh.io:kjuulh/cuddle-rust-cli-plan.git"
|
||||
base: "git@git.kjuulh.io:kjuulh/cuddle-rust-cli-plan.git"
|
||||
|
||||
vars:
|
||||
service: "noil"
|
||||
registry: kasperhermansen
|
||||
rust:
|
||||
publish: {}
|
||||
|
||||
please:
|
||||
project:
|
||||
@@ -12,6 +14,6 @@ please:
|
||||
repository: "noil"
|
||||
branch: "main"
|
||||
settings:
|
||||
api_url: "https://git.front.kjuulh.io"
|
||||
api_url: "https://git.kjuulh.io"
|
||||
actions:
|
||||
rust:
|
||||
|
@@ -12,3 +12,7 @@ rm "$tmp"
|
||||
|
||||
[tasks.test]
|
||||
run = "cargo nextest run"
|
||||
|
||||
[tasks.install]
|
||||
alias = ["i"]
|
||||
run = "cargo install --path crates/noil --force"
|
||||
|
@@ -1,15 +0,0 @@
|
||||
version: "3"
|
||||
services:
|
||||
crdb:
|
||||
restart: 'always'
|
||||
image: 'cockroachdb/cockroach:v23.1.14'
|
||||
command: 'start-single-node --advertise-addr 0.0.0.0 --insecure'
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"]
|
||||
interval: '10s'
|
||||
timeout: '30s'
|
||||
retries: 5
|
||||
start_period: '20s'
|
||||
ports:
|
||||
- 8080:8080
|
||||
- '26257:26257'
|
Reference in New Issue
Block a user