8 Commits

Author SHA1 Message Date
cuddle-please
7640eddb97 chore(release): 0.1.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-08-03 14:46:46 +00:00
4bde8ec240 feat: noil now handles open, and open in non-terminals via. /dev/tty
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2025-08-03 16:45:23 +02:00
3fdd9f93a9 feat: add file opener with chooser
All checks were successful
continuous-integration/drone/push Build is passing
Signed-off-by: kjuulh <contact@kjuulh.io>
2025-08-03 16:01:59 +02:00
139aa6d75e chore: remove cockroach
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-26 22:35:52 +02:00
a71672823c feat: update demo
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-26 22:16:34 +02:00
c0bd8c0d36 feat: add help text for preview
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-26 22:05:46 +02:00
dadec333d4 chore(release): v0.0.5 (#8)
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
chore(release): 0.0.5

Co-authored-by: cuddle-please <bot@cuddle.sh>
Reviewed-on: #8
2025-07-26 21:48:14 +02:00
18411bc52c feat: actually copy files
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-26 21:44:51 +02:00
11 changed files with 355 additions and 39 deletions

View File

@@ -6,6 +6,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.1.0] - 2025-08-03
### Added
- 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

93
Cargo.lock generated
View File

@@ -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.3"
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"

View File

@@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.0.4"
version = "0.1.0"
[workspace.dependencies]
noil = { path = "crates/noil" }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -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"

View File

@@ -1,12 +1,20 @@
use std::path::PathBuf;
use tokio::io::AsyncReadExt;
use crate::{
cli::edit::apply,
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<()> {
@@ -17,11 +25,35 @@ impl ApplyCommand {
let input = String::from_utf8_lossy(&buffer);
let action = print_changes(&input).await?;
match action {
Action::Quit => Ok(()),
Action::Apply { original } => apply(&original).await,
Action::Edit => todo!(),
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!(),
};
eprintln!("\nin preview mode: add (--commit) to perform actions");
res
} else {
apply(
&input,
ApplyOptions {
chooser_file: self.chooser_file.clone(),
..Default::default()
},
)
.await
}
}
}

View File

@@ -1,13 +1,17 @@
use std::{
env::temp_dir,
io::Write,
io::{IsTerminal, Write},
path::{Path, PathBuf},
process::Stdio,
};
use ansi_term::Color;
use anyhow::{Context, bail};
use clap::Parser;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::{
fs::OpenOptions,
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
};
use crate::{
commit::{Action, print_changes},
@@ -17,10 +21,21 @@ use crate::{
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 {
@@ -47,9 +62,13 @@ 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")?;
@@ -57,7 +76,22 @@ impl EditCommand {
let mut cmd = tokio::process::Command::new(editor.trim());
cmd.arg(&file_path);
let mut process = cmd.spawn()?;
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);
@@ -68,7 +102,13 @@ impl EditCommand {
.await
.context("read noil file")?;
let res = print_changes(&noil_content).await;
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,
@@ -78,7 +118,7 @@ impl EditCommand {
Color::Red.normal().paint(format!("{e:?}"))
);
wait_user().await?;
wait_user().await.context("user finished prematurely")?;
continue;
}
@@ -87,12 +127,38 @@ impl EditCommand {
match action {
Action::Quit => return Ok(()),
Action::Apply { original } => {
return apply(&original).await;
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());
}
if path.is_file() {
return path
.parent()
.map(|p| p.to_path_buf())
.ok_or(anyhow::anyhow!("parent doesn't exist for file"));
}
Ok(path.clone())
}
}
async fn wait_user() -> Result<(), anyhow::Error> {
@@ -101,10 +167,19 @@ async fn wait_user() -> Result<(), anyhow::Error> {
let stdin = tokio::io::stdin();
let mut reader = BufReader::new(stdin);
let mut input_buf = String::new();
reader.read_line(&mut input_buf).await?;
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.
///
@@ -112,17 +187,29 @@ async fn wait_user() -> Result<(), anyhow::Error> {
/// 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) -> anyhow::Result<()> {
eprintln!("applying changes");
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");
@@ -175,6 +262,7 @@ pub async fn apply(input: &str) -> anyhow::Result<()> {
if existing.path.is_dir() {
tracing::debug!("copying dir");
copy(&existing.path, path).await?;
continue;
}
tokio::fs::copy(&existing.path, &path)
@@ -227,6 +315,31 @@ pub async fn apply(input: &str) -> anyhow::Result<()> {
}
}
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(())
}
@@ -235,6 +348,9 @@ async fn copy(source: &Path, dest: &Path) -> anyhow::Result<()> {
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());
}
@@ -260,10 +376,12 @@ async fn copy_path(src: &Path, dest: &Path) -> anyhow::Result<()> {
}
if src.is_dir() {
tracing::info!("copying dir: {}", dest.display());
tokio::fs::create_dir_all(&dest).await.context("copy dir")?;
}
if dest.is_file() {
if src.is_file() {
tracing::info!("copying file: {}", dest.display());
tokio::fs::copy(&src, &dest).await.context("copy file")?;
}

View File

@@ -14,7 +14,7 @@ pub enum Action {
Edit,
}
pub async fn print_changes(input: &str) -> anyhow::Result<Action> {
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>) {
@@ -64,6 +64,11 @@ pub async fn print_changes(input: &str) -> anyhow::Result<Action> {
_ => {}
}
}
if preview {
return Ok(Action::Quit);
}
eprint!("\nApply changes? (y (yes) / n (abort) / E (edit)): ");
let mut stderr = std::io::stderr();
stderr.flush()?;

View File

@@ -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,
};

View File

@@ -35,6 +35,7 @@ pub enum Operation {
Copy { index: String },
Delete { index: String },
Move { index: String },
Open { index: Option<String> },
}
impl Display for Operation {
@@ -45,6 +46,7 @@ impl Display for Operation {
Operation::Copy { .. } => "COPY",
Operation::Delete { .. } => "DELETE",
Operation::Move { .. } => "MOVE",
Operation::Open { .. } => "OPEN",
};
f.write_str(op)
@@ -86,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);
}
@@ -194,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#"

View File

@@ -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'