feat: create noil

This commit is contained in:
2025-07-25 23:40:09 +02:00
commit 7b7d4f576c
12 changed files with 1934 additions and 0 deletions

2
.drone.yml Normal file
View File

@@ -0,0 +1,2 @@
kind: template
load: cuddle-rust-cli-plan.yaml

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target/
.cuddle/

1000
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[workspace]
members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "0.1.0"
[workspace.dependencies]
noil = { path = "crates/noil" }
anyhow = { version = "1" }
tokio = { version = "1", features = ["full"] }
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3.18" }
clap = { version = "4", features = ["derive", "env"] }
dotenvy = { version = "0.15" }

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# noil

1
crates/noil/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target

20
crates/noil/Cargo.toml Normal file
View File

@@ -0,0 +1,20 @@
[package]
name = "noil"
edition = "2021"
version.workspace = true
[dependencies]
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
clap.workspace = true
dotenvy.workspace = true
walkdir = "2.5.0"
ignore = "0.4.23"
blake3 = "1.8.2"
rand = "0.9.2"
[dev-dependencies]
pretty_assertions = "1.4.1"

842
crates/noil/src/main.rs Normal file
View File

@@ -0,0 +1,842 @@
use std::{
env::temp_dir,
fmt::{Display, Write},
path::{Path, PathBuf},
};
use anyhow::Context;
use clap::{Parser, Subcommand};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Command {
#[command(subcommand)]
command: Option<Commands>,
#[arg()]
path: Option<PathBuf>,
#[arg(long = "no-color", default_value = "false")]
no_color: bool,
}
#[derive(Subcommand)]
enum Commands {
Edit {
#[arg()]
path: PathBuf,
},
Apply {},
Fmt {},
}
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
const ALPHABET_LEN: u32 = ALPHABET.len() as u32;
fn encode_256bit_base36(input: &[u8; 32]) -> String {
let mut num = *input;
let mut output = Vec::with_capacity(52); // log_36(2^256) ≈ 50.7
while num.iter().any(|&b| b != 0) {
let mut rem: u32 = 0;
for byte in num.iter_mut() {
let acc = ((rem as u16) << 8) | *byte as u16;
*byte = (acc / ALPHABET_LEN as u16) as u8;
rem = (acc % ALPHABET_LEN as u16) as u32;
}
output.push(ALPHABET[rem as usize]);
}
if output.is_empty() {
output.push(ALPHABET[0]);
}
output.reverse();
String::from_utf8(output).unwrap()
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
let cli = Command::parse();
tracing::debug!("Starting cli");
match cli.command {
Some(Commands::Edit { path }) => {
let mut small_id = Vec::with_capacity(8);
for id in small_id.iter_mut() {
*id = ALPHABET[rand::random_range(0..(ALPHABET_LEN as u8)) as usize];
}
let small_id = String::from_utf8_lossy(&small_id);
let file_path = temp_dir()
.join("noil")
.join(small_id.to_string())
.join("buf.noil");
if let Some(parent) = file_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.context("failed to create temp dir file")?;
}
let mut file = tokio::fs::File::create(&file_path)
.await
.context("create temp file for noil")?;
let output = get_outputs(&path, true).await?;
file.write_all(output.as_bytes()).await?;
file.flush().await?;
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);
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}");
}
let noil_content = tokio::fs::read_to_string(&file_path)
.await
.context("read noil file")?;
write_changes(&noil_content).await?;
todo!()
}
Some(Commands::Fmt {}) => {
let mut stdin = tokio::io::stdin();
let mut buffer = Vec::new();
stdin.read_to_end(&mut buffer).await?;
let input = String::from_utf8_lossy(&buffer);
let output = format(&input)?;
let mut stdout = tokio::io::stdout();
stdout.write_all(output.as_bytes()).await?;
stdout.flush().await?;
}
Some(Commands::Apply {}) => {
let mut stdin = tokio::io::stdin();
let mut buffer = Vec::new();
stdin.read_to_end(&mut buffer).await?;
let input = String::from_utf8_lossy(&buffer);
write_changes(&input).await?;
}
None => {
let path = match &cli.path {
Some(path) => path,
None => anyhow::bail!("a path is required if just using noil"),
};
let output = get_outputs(path, cli.no_color).await?;
let mut stdout = tokio::io::stdout();
stdout.write_all(output.as_bytes()).await?;
stdout.flush().await?;
}
}
Ok(())
}
async fn write_changes(input: &str) -> anyhow::Result<()> {
let noil_index = parse(input).context("parse input")?;
fn print_op(key: &str, index: Option<&str>, path: Option<&Path>) {
match index {
Some(index) => match path {
Some(path) => println!("OP: {key} ({index}) - {}", path.display()),
None => println!("OP: {key} ({index})"),
},
None => match path {
Some(path) => {
println!("OP: {key} - {}", path.display())
}
None => println!("OP: {key}"),
},
}
}
println!("Changes: \n");
for item in noil_index.files {
match item.entry.operation {
Operation::Add => print_op("ADD", None, Some(&item.path)),
Operation::Copy { index } => print_op("COPY", Some(&index), Some(&item.path)),
Operation::Delete { index } => print_op("DELETE", Some(&index), Some(&item.path)),
Operation::Move { index } => print_op("MOVE", Some(&index), Some(&item.path)),
_ => {}
}
}
print!("\nApply changes? (y/N): ");
println!();
Ok(())
}
async fn get_outputs(path: &Path, no_color: bool) -> anyhow::Result<String> {
let mut paths = Vec::new();
for entry in ignore::WalkBuilder::new(path)
.hidden(true)
.git_ignore(true)
.ignore(true)
.build()
{
let entry = entry?;
let hash = blake3::hash(entry.path().to_string_lossy().as_bytes());
let hash_output = encode_256bit_base36(hash.as_bytes());
paths.push((hash_output, entry.into_path()));
}
paths.sort_by_key(|(h, _p)| h.clone());
let hashes = paths.iter().map(|(h, _)| h.as_str()).collect::<Vec<&str>>();
let (shortest_len, _global_prefixes, individual_prefixes) = shortest_unique_prefixes(&hashes);
let mut paths = paths
.into_iter()
.enumerate()
.map(|(index, (_, p))| (&_global_prefixes[index], &individual_prefixes[index], p))
.collect::<Vec<_>>();
paths.sort_by_key(|(_, _h, p)| p.clone());
let mut lines = Vec::new();
for (prefix, individual_prefix, path) in paths {
let path_str = path.display().to_string();
let mut line = String::new();
write!(
&mut line,
"{}{} : {}{}",
{
if no_color {
prefix
} else if let Some(suffix) = prefix.strip_prefix(individual_prefix) {
&format!("*{individual_prefix}*{suffix}")
} else {
prefix
}
},
" ".repeat(shortest_len - prefix.len()),
path_str,
{
if path.is_dir() && !path.to_string_lossy().trim_end().ends_with("/") {
"/"
} else {
""
}
}
)?;
lines.push(line);
}
Ok(lines.join("\n"))
}
fn format(input: &str) -> anyhow::Result<String> {
let noil_index = parse(input).context("parse input")?;
let max_op_len = noil_index
.files
.iter()
.map(|f| f.entry.operation.to_string().len())
.max()
.unwrap_or_default();
let max_prefix_len = noil_index
.files
.iter()
.map(|f| match &f.entry.operation {
Operation::Copy { index }
| Operation::Delete { index }
| Operation::Move { index }
| Operation::Existing { index } => index.len(),
Operation::Add => 0,
})
.max()
.unwrap_or_default();
let mut output_buf = Vec::new();
for file in noil_index.files {
let mut line = String::new();
let space = " ";
// Write operation
let operation = file.entry.operation.to_string();
if !operation.is_empty() {
let spaces = max_op_len - operation.len();
line.write_str(&operation)?;
line.write_str(&space.repeat(spaces))?;
} else {
line.write_str(&space.repeat(max_op_len))?;
}
if max_op_len > 0 {
line.write_str(&space.repeat(3))?;
}
// Write index
let index = match file.entry.operation {
Operation::Copy { index }
| Operation::Delete { index }
| Operation::Move { index }
| Operation::Existing { index } => Some(index),
Operation::Add => None,
};
if let Some(index) = index {
let spaces = max_prefix_len - index.len();
line.write_str(&index)?;
line.write_str(&space.repeat(spaces))?;
} else {
line.write_str(&space.repeat(max_prefix_len))?;
}
if max_prefix_len > 0 {
line.write_str(&space.repeat(3))?;
}
// Write divider
line.write_str(":")?;
line.write_str(&space.repeat(3))?;
// Write path
line.write_str(&file.path.display().to_string())?;
output_buf.push(line);
}
let output = output_buf.join("\n");
Ok(output)
}
#[cfg(test)]
mod test_format {
#[test]
fn can_format_complex_file() -> anyhow::Result<()> {
let input = r#"
C asdf : /Somasdlf
as : /bla/bla/bla
MOVE assdfasdf : /bla/bla/bla
RENAME asdf23 : /bla/bla/bla
a : /bla/bla/bla
123 : /123123/1231
"#;
let expected = r#"
COPY asdf : /Somasdlf
as : /bla/bla/bla
MOVE assdfasdf : /bla/bla/bla
MOVE asdf23 : /bla/bla/bla
a : /bla/bla/bla
123 : /123123/1231
"#
.trim();
let output = super::format(input)?;
pretty_assertions::assert_eq!(expected, &output);
Ok(())
}
#[test]
fn can_format_no_op() -> anyhow::Result<()> {
let input = r#"
asdf : /Somasdlf
as : /bla/bla/bla
assdfasdf : /bla/bla/bla
asdf23 : /bla/bla/bla
a : /bla/bla/bla
123 : /123123/1231
"#;
let expected = r#"
asdf : /Somasdlf
as : /bla/bla/bla
assdfasdf : /bla/bla/bla
asdf23 : /bla/bla/bla
a : /bla/bla/bla
123 : /123123/1231
"#
.trim();
let output = super::format(input)?;
pretty_assertions::assert_eq!(expected, &output);
Ok(())
}
}
#[derive(Clone, PartialEq, Debug)]
pub struct Buffer {
files: Vec<File>,
}
#[derive(Clone, PartialEq, Debug)]
pub struct File {
path: PathBuf,
entry: FileEntry,
}
#[derive(Clone, PartialEq, Debug)]
pub struct FileEntry {
raw_op: Option<String>,
operation: Operation,
}
#[derive(Clone, PartialEq, Debug)]
pub enum Operation {
Existing { index: String },
Add,
Copy { index: String },
Delete { index: String },
Move { index: String },
}
impl Display for Operation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let op = match self {
Operation::Existing { .. } => "",
Operation::Add => "ADD",
Operation::Copy { .. } => "COPY",
Operation::Delete { .. } => "DELETE",
Operation::Move { .. } => "MOVE",
};
f.write_str(op)
}
}
impl FileEntry {
fn parse(file_entry: &str) -> anyhow::Result<Self> {
let items = file_entry.split(' ').collect::<Vec<_>>();
// get left most non-empty
let Some(first) = items.first() else {
anyhow::bail!("not a valid file entry, doesn't contain anything");
};
let Some(last) = items.last() else {
anyhow::bail!("not a valid file entry, doesn't contain anything");
};
if first == last && !first.chars().any(|c| c.is_uppercase()) {
// We've got a raw index
return Ok(Self {
raw_op: None,
operation: Operation::Existing {
index: first.to_string(),
},
});
}
let index = last.to_string();
let op = match *first {
// ADD: first == last is sanity check there there is nothing else for this operation
"A" | "ADD" if first == last => Operation::Add {},
// COPY: First cannot be equal last here, otherwise there is no index
"C" | "COPY" if first != last => Operation::Copy { index },
// DELETE:
"D" | "DEL" | "DELETE" if first != last => Operation::Delete { index },
// MOVE:
"M" | "MV" | "MOVE" | "RENAME" if first != last => Operation::Move { index },
o => {
anyhow::bail!("operation: {} is not supported", o);
}
};
Ok(FileEntry {
raw_op: Some(first.to_string()),
operation: op,
})
}
}
#[cfg(test)]
mod test_2 {
use crate::{parse, Buffer, File, FileEntry};
#[test]
fn can_parse_item() -> anyhow::Result<()> {
let input = r#"
abc : /var/my
ecd : /var/my/path
"#;
let output = parse(input)?;
pretty_assertions::assert_eq!(
Buffer {
files: vec![
File {
path: "/var/my".into(),
entry: FileEntry {
raw_op: None,
operation: crate::Operation::Existing {
index: "abc".into()
},
},
},
File {
path: "/var/my/path".into(),
entry: FileEntry {
raw_op: None,
operation: crate::Operation::Existing {
index: "ecd".into()
}
}
}
]
},
output
);
Ok(())
}
#[test]
fn can_parse_item_add_operation() -> anyhow::Result<()> {
let input = r#"
abc : /var/my
ecd : /var/my/path
A : /var/my/path/new-path
ADD : /var/my/path/new-long-path
"#;
let output = parse(input)?;
pretty_assertions::assert_eq!(
Buffer {
files: vec![
File {
path: "/var/my".into(),
entry: FileEntry {
raw_op: None,
operation: crate::Operation::Existing {
index: "abc".into()
}
},
},
File {
path: "/var/my/path".into(),
entry: FileEntry {
raw_op: None,
operation: crate::Operation::Existing {
index: "ecd".into()
}
},
},
File {
path: "/var/my/path/new-path".into(),
entry: FileEntry {
raw_op: Some("A".into()),
operation: crate::Operation::Add,
},
},
File {
path: "/var/my/path/new-long-path".into(),
entry: FileEntry {
raw_op: Some("ADD".into()),
operation: crate::Operation::Add,
}
}
]
},
output
);
Ok(())
}
#[test]
fn can_parse_item_copy_operation() -> anyhow::Result<()> {
let input = r#"
abc : /var/my
ecd : /var/my/path
C abc : /var/my/path/copy-into
COPY ecd : /var/my/path/copy-into-long
"#;
let output = parse(input)?;
pretty_assertions::assert_eq!(
Buffer {
files: vec![
File {
path: "/var/my".into(),
entry: FileEntry {
raw_op: None,
operation: crate::Operation::Existing {
index: "abc".into()
}
},
},
File {
path: "/var/my/path".into(),
entry: FileEntry {
raw_op: None,
operation: crate::Operation::Existing {
index: "ecd".into()
}
},
},
File {
path: "/var/my/path/copy-into".into(),
entry: FileEntry {
raw_op: Some("C".into()),
operation: crate::Operation::Copy {
index: "abc".into()
},
},
},
File {
path: "/var/my/path/copy-into-long".into(),
entry: FileEntry {
raw_op: Some("COPY".into()),
operation: crate::Operation::Copy {
index: "ecd".into()
},
}
}
]
},
output
);
Ok(())
}
#[test]
fn can_parse_item_delete_operation() -> anyhow::Result<()> {
let input = r#"
D abc : /var/my
DEL ecd : /var/my/path
DELETE ecd : /var/my/path
"#;
let output = parse(input)?;
pretty_assertions::assert_eq!(
Buffer {
files: vec![
File {
path: "/var/my".into(),
entry: FileEntry {
raw_op: Some("D".into()),
operation: crate::Operation::Delete {
index: "abc".into()
}
},
},
File {
path: "/var/my/path".into(),
entry: FileEntry {
raw_op: Some("DEL".into()),
operation: crate::Operation::Delete {
index: "ecd".into()
}
},
},
File {
path: "/var/my/path".into(),
entry: FileEntry {
raw_op: Some("DELETE".into()),
operation: crate::Operation::Delete {
index: "ecd".into()
}
},
},
]
},
output
);
Ok(())
}
#[test]
fn can_parse_item_move_operation() -> anyhow::Result<()> {
let input = r#"
abc : /var/my
ecd : /var/my/path
M abc : /var/my/some-different-place
MV ecd : /var/my/some-different-place
MOVE ecd : /var/my/some-different-place
RENAME ecd : /var/my/some-different-place
"#;
let output = parse(input)?;
pretty_assertions::assert_eq!(
Buffer {
files: vec![
File {
path: "/var/my".into(),
entry: FileEntry {
raw_op: None,
operation: crate::Operation::Existing {
index: "abc".into()
}
},
},
File {
path: "/var/my/path".into(),
entry: FileEntry {
raw_op: None,
operation: crate::Operation::Existing {
index: "ecd".into()
}
},
},
File {
path: "/var/my/some-different-place".into(),
entry: FileEntry {
raw_op: Some("M".into()),
operation: crate::Operation::Move {
index: "abc".into()
}
},
},
File {
path: "/var/my/some-different-place".into(),
entry: FileEntry {
raw_op: Some("MV".into()),
operation: crate::Operation::Move {
index: "ecd".into()
}
},
},
File {
path: "/var/my/some-different-place".into(),
entry: FileEntry {
raw_op: Some("MOVE".into()),
operation: crate::Operation::Move {
index: "ecd".into()
}
},
},
File {
path: "/var/my/some-different-place".into(),
entry: FileEntry {
raw_op: Some("RENAME".into()),
operation: crate::Operation::Move {
index: "ecd".into()
}
},
},
]
},
output
);
Ok(())
}
}
fn parse(input: &str) -> anyhow::Result<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() {
if let Some((left, right)) = line.trim().rsplit_once(" : ") {
let path = PathBuf::from(right.trim());
let file_entry = FileEntry::parse(left.trim())?;
files.push(File {
path,
entry: file_entry,
})
}
}
Ok(Buffer { files })
}
fn shortest_unique_prefixes(values: &[&str]) -> (usize, Vec<String>, Vec<String>) {
if values.is_empty() {
return (0, Vec::new(), Vec::new());
}
let len = values[0].len();
let mut global_prefix_len = 0;
let mut individual_prefixes = Vec::with_capacity(values.len());
// Helper to find shared prefix length
fn shared_prefix_len(a: &str, b: &str) -> usize {
a.chars()
.zip(b.chars())
.take_while(|(ac, bc)| ac == bc)
.count()
}
for i in 0..values.len() {
let cur = values[i];
let mut max_shared = 0;
if i > 0 {
max_shared = max_shared.max(shared_prefix_len(cur, values[i - 1]));
}
if i + 1 < values.len() {
max_shared = max_shared.max(shared_prefix_len(cur, values[i + 1]));
}
// Add 1 to ensure uniqueness
let unique_len = (max_shared + 1).min(len);
individual_prefixes.push(cur[..unique_len].to_string());
// For global prefix: max shared between any two neighbors
if i + 1 < values.len() {
global_prefix_len = global_prefix_len.max(shared_prefix_len(cur, values[i + 1]) + 1);
}
}
global_prefix_len = global_prefix_len.min(len);
let global_prefixes = values
.iter()
.map(|s| s[..global_prefix_len].to_string())
.collect();
(global_prefix_len, global_prefixes, individual_prefixes)
}
#[cfg(test)]
mod test {
use crate::shortest_unique_prefixes;
#[test]
fn simple_shortest() {
let mut input = vec!["1ab", "3ab", "1ca"];
let expected_len: usize = 2;
let expected_global: Vec<String> = vec!["1a".into(), "1c".into(), "3a".into()];
let expected_individual: Vec<String> = vec!["1a".into(), "1c".into(), "3".into()];
input.sort();
let (len, global_prefixes, individual_prefixes) = shortest_unique_prefixes(&input);
assert_eq!(expected_len, len);
assert_eq!(expected_global, global_prefixes);
assert_eq!(expected_individual, individual_prefixes);
}
}

17
cuddle.yaml Normal file
View File

@@ -0,0 +1,17 @@
# yaml-language-server: $schema=https://git.front.kjuulh.io/kjuulh/cuddle/raw/branch/main/schemas/base.json
base: "git@git.front.kjuulh.io:kjuulh/cuddle-rust-cli-plan.git"
vars:
service: "noil"
registry: kasperhermansen
please:
project:
owner: kjuulh
repository: "noil"
branch: "main"
settings:
api_url: "https://git.front.kjuulh.io"
actions:
rust:

14
mise.toml Normal file
View File

@@ -0,0 +1,14 @@
[tasks."run:edit"]
run = """#!/usr/bin/env zsh
set -e
tmp=$(mktemp)
cargo run -- --no-color target/ > "$tmp" || return 1
${EDITOR:-hx} "$tmp"
cargo run -- apply < "$tmp"
rm "$tmp"
"""
[tasks.test]
run = "cargo nextest run"

3
renovate.json Normal file
View File

@@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

View File

@@ -0,0 +1,15 @@
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'