v0.2.9 : ps/where/get facon Nushell avec mini-pipeline (ls | where size > 1mb | get name), dir = ls sur Windows, ls beaucoup plus rapide (taille des dossiers bornee a 500 entrees), autocomplet : prefixe commun + menu avec descriptions + fallback chemins
This commit is contained in:
Generated
+2
-1
@@ -21,7 +21,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-manager"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -41,6 +41,7 @@ dependencies = [
|
||||
"ureq",
|
||||
"wait-timeout",
|
||||
"which",
|
||||
"windows-sys 0.52.0",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "agent-manager"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
edition = "2021"
|
||||
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
|
||||
license = "MIT"
|
||||
@@ -35,6 +35,9 @@ wait-timeout = "0.2"
|
||||
which = "7"
|
||||
zip = "0.6"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_System_Diagnostics_ToolHelp"] }
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
strip = true
|
||||
|
||||
@@ -182,7 +182,12 @@ démarrage et mis en évidence** dans la bannière (config
|
||||
PATH — pwsh, powershell, cmd, bash, zsh, fish, sh, nu, elvish).
|
||||
|
||||
❯ ls # liste façon Nushell : tableau encadré (type, taille, date)
|
||||
❯ dir # sur Windows, même affichage que ls
|
||||
❯ ls src # un chemin précis — Tab complète fichiers et dossiers
|
||||
❯ ps # liste des processus (pid, ppid, cpu, mem, threads)
|
||||
❯ ls | where size > 1mb # filtre la dernière table (>, <, >=, <=, ==, !=, =~)
|
||||
❯ ps | where name =~ am # =~ cherche dans le texte
|
||||
❯ ls | get name # sélectionne une colonne
|
||||
❯ cd <Tab> # complétion des dossiers uniquement, un niveau par Tab
|
||||
❯ shell # affiche le shell courant + les shells disponibles
|
||||
❯ shell bash # change le shell de la session
|
||||
|
||||
@@ -23,11 +23,13 @@ pub mod installers;
|
||||
pub mod nav;
|
||||
pub mod output;
|
||||
pub mod probe;
|
||||
pub mod ps;
|
||||
pub mod process;
|
||||
pub mod repl;
|
||||
pub mod runner;
|
||||
pub mod shell;
|
||||
pub mod state;
|
||||
pub mod tables;
|
||||
pub mod toolchain;
|
||||
pub mod version;
|
||||
|
||||
|
||||
+73
-145
@@ -1,6 +1,7 @@
|
||||
//! Nushell-inspired navigation helpers: a boxed table listing of files and
|
||||
//! directories (ls), and directory/path completion for Tab.
|
||||
|
||||
use crate::tables::{Cell, DataTable};
|
||||
use anyhow::{Context, Result};
|
||||
use colored::Colorize;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -22,10 +23,11 @@ pub fn format_size(bytes: u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive size of a directory, bounded: beyond the entry cap the size is
|
||||
/// reported as unknown (None) so huge trees never freeze the listing.
|
||||
/// Recursive size of a directory, tightly bounded: beyond the entry cap the
|
||||
/// size is reported as unknown (None). The cap is deliberately small so a
|
||||
/// listing stays snappy even when it contains huge trees.
|
||||
pub fn dir_size(path: &Path) -> Option<u64> {
|
||||
const CAP: usize = 20_000;
|
||||
const CAP: usize = 500;
|
||||
let mut total: u64 = 0;
|
||||
let mut stack: Vec<PathBuf> = vec![path.to_path_buf()];
|
||||
let mut visited: usize = 0;
|
||||
@@ -49,14 +51,6 @@ pub fn dir_size(path: &Path) -> Option<u64> {
|
||||
Some(total)
|
||||
}
|
||||
|
||||
/// One entry of the ls table.
|
||||
struct LsRow {
|
||||
name: String,
|
||||
kind: &'static str,
|
||||
size: String,
|
||||
modified: String,
|
||||
}
|
||||
|
||||
fn modified_str(path: &Path) -> String {
|
||||
std::fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
@@ -81,14 +75,31 @@ fn paint(s: &str, color: bool, style: u8) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a Nushell-style boxed table of the entries of a path, with the
|
||||
/// columns: index, name, type, size, modified. Directories come first, then
|
||||
/// alphabetical order; sizes are human-readable and directories show their
|
||||
/// recursive size (bounded).
|
||||
pub fn render_ls(path: &Path, color: bool) -> Result<String> {
|
||||
fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
meta.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = meta;
|
||||
matches!(
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.as_deref(),
|
||||
Some("exe") | Some("bat") | Some("cmd") | Some("com")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the ls table (columns: name, type, size, modified). Directories
|
||||
/// come first, then alphabetical order.
|
||||
pub fn ls_table(path: &Path, color: bool) -> Result<DataTable> {
|
||||
let rd = std::fs::read_dir(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let mut rows: Vec<LsRow> = Vec::new();
|
||||
let mut rows: Vec<(String, Vec<Cell>)> = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let p = entry.path();
|
||||
let meta = match std::fs::symlink_metadata(&p) {
|
||||
@@ -109,13 +120,13 @@ pub fn render_ls(path: &Path, color: bool) -> Result<String> {
|
||||
} else {
|
||||
("file", 0)
|
||||
};
|
||||
let size = if file_type.is_dir() {
|
||||
let (size_text, size_bytes) = if file_type.is_dir() {
|
||||
match dir_size(&p) {
|
||||
Some(s) => format_size(s),
|
||||
None => "—".to_string(),
|
||||
Some(s) => (format_size(s), Some(s)),
|
||||
None => ("—".to_string(), None),
|
||||
}
|
||||
} else {
|
||||
format_size(meta.len())
|
||||
(format_size(meta.len()), Some(meta.len()))
|
||||
};
|
||||
let mut display = name.clone();
|
||||
if file_type.is_dir() {
|
||||
@@ -125,114 +136,36 @@ pub fn render_ls(path: &Path, color: bool) -> Result<String> {
|
||||
display.push_str(&format!(" -> {}", target.display()));
|
||||
}
|
||||
}
|
||||
rows.push(LsRow {
|
||||
name: paint(&display, color, style),
|
||||
kind,
|
||||
size,
|
||||
modified: modified_str(&p),
|
||||
});
|
||||
rows.push((
|
||||
name.to_lowercase(),
|
||||
vec![
|
||||
Cell::str(paint(&display, color, style)),
|
||||
Cell::str(kind.to_string()),
|
||||
Cell::bytes(size_bytes.unwrap_or(0), size_text),
|
||||
Cell::str(modified_str(&p)),
|
||||
],
|
||||
));
|
||||
}
|
||||
// Directories first, then alphabetical (case-insensitive).
|
||||
// Directories first, then alphabetical.
|
||||
rows.sort_by(|a, b| {
|
||||
let a_dir = a.kind == "dir";
|
||||
let b_dir = b.kind == "dir";
|
||||
b_dir
|
||||
.cmp(&a_dir)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
let a_dir = a.1[1].text == "dir";
|
||||
let b_dir = b.1[1].text == "dir";
|
||||
b_dir.cmp(&a_dir).then_with(|| a.0.cmp(&b.0))
|
||||
});
|
||||
|
||||
// Column contents: index, name, type, size, modified.
|
||||
let headers = vec!["#", "name", "type", "size", "modified"];
|
||||
let mut widths: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
|
||||
let mut cells: Vec<Vec<String>> = Vec::new();
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let line = vec![
|
||||
i.to_string(),
|
||||
row.name.clone(),
|
||||
row.kind.to_string(),
|
||||
row.size.clone(),
|
||||
row.modified.clone(),
|
||||
];
|
||||
for (c, cell) in line.iter().enumerate() {
|
||||
widths[c] = widths[c].max(visible_len(cell));
|
||||
}
|
||||
cells.push(line);
|
||||
}
|
||||
|
||||
let pad = |s: &str, w: usize| -> String {
|
||||
let n = visible_len(s);
|
||||
format!("{s}{}", " ".repeat(w.saturating_sub(n)))
|
||||
};
|
||||
let border = |left: char, mid: char, right: char| -> String {
|
||||
let mut line = String::new();
|
||||
line.push(left);
|
||||
for (i, w) in widths.iter().enumerate() {
|
||||
if i > 0 {
|
||||
line.push(mid);
|
||||
}
|
||||
line.push_str(&"─".repeat(w + 2));
|
||||
}
|
||||
line.push(right);
|
||||
line
|
||||
};
|
||||
let row = |cells: &[String], bold: bool| -> String {
|
||||
let mut line = String::from("│");
|
||||
for (i, c) in cells.iter().enumerate() {
|
||||
line.push(' ');
|
||||
let cell = pad(c, widths[i]);
|
||||
let rendered = if bold {
|
||||
cell.bold().to_string()
|
||||
} else {
|
||||
cell
|
||||
};
|
||||
line.push_str(&rendered);
|
||||
line.push(' ');
|
||||
line.push('│');
|
||||
}
|
||||
line
|
||||
};
|
||||
|
||||
let dim = |s: &str| -> String {
|
||||
if color {
|
||||
s.dimmed().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&dim(&border('╭', '┬', '╮')));
|
||||
out.push('\n');
|
||||
out.push_str(&row(&headers.iter().map(|h| h.to_string()).collect::<Vec<_>>(), true));
|
||||
out.push('\n');
|
||||
out.push_str(&dim(&border('├', '┼', '┤')));
|
||||
out.push('\n');
|
||||
for line in &cells {
|
||||
out.push_str(&row(line, false));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&dim(&border('╰', '┴', '╯')));
|
||||
out.push('\n');
|
||||
Ok(out)
|
||||
Ok(DataTable {
|
||||
columns: vec![
|
||||
"name".into(),
|
||||
"type".into(),
|
||||
"size".into(),
|
||||
"modified".into(),
|
||||
],
|
||||
rows: rows.into_iter().map(|(_, r)| r).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_executable(path: &Path, meta: &std::fs::Metadata) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
meta.permissions().mode() & 0o111 != 0
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = meta;
|
||||
matches!(
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.as_deref(),
|
||||
Some("exe") | Some("bat") | Some("cmd") | Some("com")
|
||||
)
|
||||
}
|
||||
/// Render a Nushell-style boxed table of the entries of a path.
|
||||
pub fn render_ls(path: &Path, color: bool) -> Result<String> {
|
||||
Ok(ls_table(path, color)?.render(color))
|
||||
}
|
||||
|
||||
/// Completion candidates for a path fragment typed after cd or ls.
|
||||
@@ -294,27 +227,6 @@ pub fn path_candidates(base_dir: &Path, input: &str, dirs_only: bool) -> Vec<Str
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\u{1b}' {
|
||||
for n in chars.by_ref() {
|
||||
if n == 'm' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn visible_len(s: &str) -> usize {
|
||||
strip_ansi(s).chars().count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -328,6 +240,22 @@ mod tests {
|
||||
assert_eq!(format_size(3 * 1024 * 1024 * 1024), "3.0 GB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dir_size_is_bounded() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let big = dir.path().join("big");
|
||||
std::fs::create_dir_all(&big).unwrap();
|
||||
for i in 0..520 {
|
||||
std::fs::write(big.join(format!("f{i}.txt")), "x").unwrap();
|
||||
}
|
||||
// Beyond the cap the size is unknown, and the walk stays cheap.
|
||||
assert_eq!(dir_size(&big), None);
|
||||
let small = dir.path().join("small");
|
||||
std::fs::create_dir_all(&small).unwrap();
|
||||
std::fs::write(small.join("a.txt"), "hello").unwrap();
|
||||
assert_eq!(dir_size(&small), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_directories_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! ps: Nushell-style process table.
|
||||
//!
|
||||
//! Windows uses the Toolhelp32 snapshot API (pid, ppid, name, threads, cpu
|
||||
//! time, working set); Unix reads /proc.
|
||||
|
||||
use crate::tables::{Cell, DataTable};
|
||||
|
||||
/// Columns: pid, ppid, name, cpu, mem, threads.
|
||||
fn columns() -> Vec<String> {
|
||||
vec![
|
||||
"pid".into(),
|
||||
"ppid".into(),
|
||||
"name".into(),
|
||||
"cpu".into(),
|
||||
"mem".into(),
|
||||
"threads".into(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Build the process table.
|
||||
pub fn ps_table() -> DataTable {
|
||||
#[cfg(windows)]
|
||||
let rows = windows_rows();
|
||||
#[cfg(unix)]
|
||||
let rows = unix_rows();
|
||||
#[cfg(not(any(windows, unix)))]
|
||||
let rows = Vec::new();
|
||||
|
||||
let mut rows = rows;
|
||||
rows.sort_by(|a, b| {
|
||||
a[0]
|
||||
.num
|
||||
.unwrap_or(0.0)
|
||||
.partial_cmp(&b[0].num.unwrap_or(0.0))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
DataTable {
|
||||
columns: columns(),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Windows: Toolhelp32 snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_rows() -> Vec<Vec<Cell>> {
|
||||
use crate::nav::format_size;
|
||||
use std::mem::size_of;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||
TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::ProcessStatus::{
|
||||
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_INFORMATION,
|
||||
};
|
||||
|
||||
let mut rows: Vec<Vec<Cell>> = Vec::new();
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return rows;
|
||||
}
|
||||
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
|
||||
entry.dwSize = size_of::<PROCESSENTRY32W>() as u32;
|
||||
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||
while ok != 0 {
|
||||
let pid = entry.th32ProcessID;
|
||||
let ppid = entry.th32ParentProcessID;
|
||||
let threads = entry.cntThreads;
|
||||
let mut name = String::new();
|
||||
for &unit in entry.szExeFile.iter() {
|
||||
if unit == 0 {
|
||||
break;
|
||||
}
|
||||
if let Some(c) = char::from_u32(unit as u32) {
|
||||
name.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
// CPU time and memory need a process handle; some system processes
|
||||
// refuse — those cells stay "—".
|
||||
let mut cpu = "—".to_string();
|
||||
let mut mem = "—".to_string();
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid) };
|
||||
if handle != 0 {
|
||||
let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { std::mem::zeroed() };
|
||||
if unsafe {
|
||||
K32GetProcessMemoryInfo(
|
||||
handle,
|
||||
&mut counters,
|
||||
size_of::<PROCESS_MEMORY_COUNTERS>() as u32,
|
||||
)
|
||||
} != 0
|
||||
{
|
||||
mem = format_size(counters.WorkingSetSize as u64);
|
||||
}
|
||||
let (mut created, mut exited, mut kernel, mut user): (
|
||||
windows_sys::Win32::Foundation::FILETIME,
|
||||
windows_sys::Win32::Foundation::FILETIME,
|
||||
windows_sys::Win32::Foundation::FILETIME,
|
||||
windows_sys::Win32::Foundation::FILETIME,
|
||||
) = unsafe {
|
||||
(
|
||||
std::mem::zeroed(),
|
||||
std::mem::zeroed(),
|
||||
std::mem::zeroed(),
|
||||
std::mem::zeroed(),
|
||||
)
|
||||
};
|
||||
if unsafe { GetProcessTimes(handle, &mut created, &mut exited, &mut kernel, &mut user) } != 0 {
|
||||
let ticks = (filetime_u64(&kernel) + filetime_u64(&user)) as f64 / 10_000_000.0;
|
||||
cpu = format!("{ticks:.0}s");
|
||||
}
|
||||
unsafe { CloseHandle(handle) };
|
||||
}
|
||||
|
||||
rows.push(vec![
|
||||
Cell::num(pid as f64, pid.to_string()),
|
||||
Cell::num(ppid as f64, ppid.to_string()),
|
||||
Cell::str(name),
|
||||
Cell::str(cpu),
|
||||
Cell::str(mem),
|
||||
Cell::num(threads as f64, threads.to_string()),
|
||||
]);
|
||||
ok = unsafe { Process32NextW(snapshot, &mut entry) };
|
||||
}
|
||||
unsafe { CloseHandle(snapshot) };
|
||||
rows
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn filetime_u64(ft: &windows_sys::Win32::Foundation::FILETIME) -> u64 {
|
||||
((ft.dwHighDateTime as u64) << 32) | (ft.dwLowDateTime as u64)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unix: /proc
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(unix)]
|
||||
fn unix_rows() -> Vec<Vec<Cell>> {
|
||||
use crate::nav::format_size;
|
||||
let page_size = page_size();
|
||||
let mut rows: Vec<Vec<Cell>> = Vec::new();
|
||||
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||
return rows;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let Ok(pid) = name.parse::<f64>() else {
|
||||
continue;
|
||||
};
|
||||
let base = entry.path();
|
||||
let Ok(stat) = std::fs::read_to_string(base.join("stat")) else {
|
||||
continue;
|
||||
};
|
||||
// The comm field is in parentheses and may contain spaces.
|
||||
let Some(close) = stat.rfind(')') else {
|
||||
continue;
|
||||
};
|
||||
let comm = stat[stat.find('(').unwrap_or(0) + 1..close].to_string();
|
||||
let rest: Vec<&str> = stat[close + 2..].split_whitespace().collect();
|
||||
if rest.len() < 22 {
|
||||
continue;
|
||||
}
|
||||
// rest[0]=state, rest[1]=ppid, rest[11]=utime, rest[12]=stime,
|
||||
// rest[17]=num_threads, rest[21]=rss (pages).
|
||||
let ppid = rest[1].parse::<f64>().unwrap_or(0.0);
|
||||
let utime = rest[11].parse::<f64>().unwrap_or(0.0);
|
||||
let stime = rest[12].parse::<f64>().unwrap_or(0.0);
|
||||
let threads = rest[17].parse::<f64>().unwrap_or(0.0);
|
||||
let rss_pages = rest[21].parse::<u64>().unwrap_or(0);
|
||||
let cpu = (utime + stime) / 100.0;
|
||||
rows.push(vec![
|
||||
Cell::num(pid, pid.to_string()),
|
||||
Cell::num(ppid, ppid.to_string()),
|
||||
Cell::str(comm),
|
||||
Cell::str(format!("{cpu:.0}s")),
|
||||
Cell::str(format_size(rss_pages * page_size)),
|
||||
Cell::num(threads, threads.to_string()),
|
||||
]);
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn page_size() -> u64 {
|
||||
// Standard page size; RSS in pages * 4096 is close enough for display.
|
||||
4096
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ps_produces_a_table() {
|
||||
let table = ps_table();
|
||||
assert_eq!(
|
||||
table.columns,
|
||||
vec!["pid", "ppid", "name", "cpu", "mem", "threads"]
|
||||
);
|
||||
assert!(!table.rows.is_empty(), "process table should not be empty");
|
||||
// Our own pid is in the table.
|
||||
let me = std::process::id().to_string();
|
||||
assert!(
|
||||
table.rows.iter().any(|r| r[0].text == me),
|
||||
"our pid should be listed"
|
||||
);
|
||||
// Rows have as many cells as columns.
|
||||
assert!(table.rows.iter().all(|r| r.len() == 6));
|
||||
}
|
||||
}
|
||||
+152
-20
@@ -16,6 +16,8 @@ use crate::app::App;
|
||||
use crate::cli::{Cli, Command, StartArgs};
|
||||
use crate::commands;
|
||||
use crate::nav;
|
||||
use crate::ps;
|
||||
use crate::tables::DataTable;
|
||||
use crate::shell::ShellSession;
|
||||
use anyhow::{anyhow, Result};
|
||||
use clap::CommandFactory;
|
||||
@@ -72,7 +74,11 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
||||
("import", "restore a backup"),
|
||||
("shell", "show or switch the system shell"),
|
||||
("ls", "list files and directories (Nushell-style)"),
|
||||
("dir", "list files and directories (same as ls)"),
|
||||
("cd", "change directory — Tab completes folders"),
|
||||
("ps", "list processes"),
|
||||
("where", "filter the last table — where size > 1mb"),
|
||||
("get", "select a column — get name"),
|
||||
("help", "show this help"),
|
||||
("version", "print the version"),
|
||||
("exit", "quit the shell"),
|
||||
@@ -118,8 +124,8 @@ impl AmCompleter {
|
||||
commands: vec![
|
||||
"list", "status", "search", "info", "install", "uninstall", "update",
|
||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "ls", "cd",
|
||||
"help", "version", "exit",
|
||||
"self-update", "self-uninstall", "export", "import", "shell", "ls", "dir",
|
||||
"cd", "ps", "where", "get", "help", "version", "exit",
|
||||
],
|
||||
config_sub: vec!["show", "path", "edit", "validate", "add"],
|
||||
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
|
||||
@@ -171,7 +177,7 @@ impl AmCompleter {
|
||||
let cwd = current_dir();
|
||||
pool = crate::nav::path_candidates(&cwd, word, true);
|
||||
}
|
||||
"ls" => {
|
||||
"ls" | "dir" => {
|
||||
// Complete paths (files and directories).
|
||||
let cwd = current_dir();
|
||||
pool = crate::nav::path_candidates(&cwd, word, false);
|
||||
@@ -194,6 +200,11 @@ impl AmCompleter {
|
||||
_ => {
|
||||
if word.starts_with('-') {
|
||||
pool = self.options.iter().map(|s| s.to_string()).collect();
|
||||
} else {
|
||||
// Nushell fallback: complete file paths for external
|
||||
// (system) commands when nothing else applies.
|
||||
let cwd = current_dir();
|
||||
pool = crate::nav::path_candidates(&cwd, word, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,7 +417,9 @@ pub fn banner_box(
|
||||
" system doctor · config · completion · self-update · self-uninstall · export · import"
|
||||
.to_string(),
|
||||
));
|
||||
rows.push(inner(" shell ls · cd · shell · help · version · exit".to_string()));
|
||||
rows.push(inner(
|
||||
" shell ls · cd · ps · where · get · shell · help · version · exit".to_string(),
|
||||
));
|
||||
|
||||
// Stats footer.
|
||||
rows.push(blank());
|
||||
@@ -648,11 +661,17 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
|
||||
rustyline::Editor::new()?;
|
||||
rl.set_helper(Some(AmCompleter::new(app)));
|
||||
// Nushell-style completion: a single match is inserted immediately;
|
||||
// otherwise Tab inserts the longest common prefix and the menu lists the
|
||||
// candidates with their descriptions.
|
||||
use rustyline::config::{CompletionType, Configurer};
|
||||
let _ = rl.set_completion_type(CompletionType::List);
|
||||
let history_path = app.paths.state_file.with_file_name("history.txt");
|
||||
if rl.load_history(&history_path).is_err() {
|
||||
// First run: no history yet.
|
||||
}
|
||||
let started = Instant::now();
|
||||
let mut last: Option<DataTable> = None;
|
||||
loop {
|
||||
print_status(app, session, started);
|
||||
match rl.readline("❯ ") {
|
||||
@@ -662,7 +681,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
continue;
|
||||
}
|
||||
let _ = rl.add_history_entry(trimmed.as_str());
|
||||
match handle_input(app, session, &trimmed) {
|
||||
match handle_input(app, session, &mut last, &trimmed) {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {}
|
||||
Err(e) => app.log.error(&format!("{e:#}")),
|
||||
@@ -683,6 +702,7 @@ fn run_with_editor(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
/// Plain fallback when no terminal/line editor is available.
|
||||
fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
let started = Instant::now();
|
||||
let mut last: Option<DataTable> = None;
|
||||
loop {
|
||||
print_status(app, session, started);
|
||||
eprint!("❯ ");
|
||||
@@ -695,7 +715,7 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match handle_input(app, session, &line) {
|
||||
match handle_input(app, session, &mut last, &line) {
|
||||
Ok(true) => break,
|
||||
Ok(false) => {}
|
||||
Err(e) => app.log.error(&format!("{e:#}")),
|
||||
@@ -705,7 +725,12 @@ fn run_plain(app: &App, session: &mut ShellSession) -> Result<i32> {
|
||||
}
|
||||
|
||||
/// Handle one line of input. Returns true when the session should exit.
|
||||
fn handle_input(app: &App, session: &mut ShellSession, line: &str) -> Result<bool> {
|
||||
fn handle_input(
|
||||
app: &App,
|
||||
session: &mut ShellSession,
|
||||
last: &mut Option<DataTable>,
|
||||
line: &str,
|
||||
) -> Result<bool> {
|
||||
match line {
|
||||
"exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true),
|
||||
"help" | "?" | "/help" => {
|
||||
@@ -732,12 +757,21 @@ fn handle_input(app: &App, session: &mut ShellSession, line: &str) -> Result<boo
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
handle_line(app, session, line)
|
||||
handle_line(app, session, last, line)
|
||||
}
|
||||
|
||||
/// Handle one line of input. Returns true when the session should exit
|
||||
/// (only after a successful self-uninstall: the binary is gone).
|
||||
fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result<bool> {
|
||||
fn handle_line(
|
||||
app: &App,
|
||||
session: &mut ShellSession,
|
||||
last: &mut Option<DataTable>,
|
||||
line: &str,
|
||||
) -> Result<bool> {
|
||||
// Nushell-style pipelines: ls | where ... | get ...
|
||||
if line.contains('|') {
|
||||
return eval_pipeline(app, last, line);
|
||||
}
|
||||
// '!' forces system execution even for words that look like am commands.
|
||||
if let Some(rest) = line.strip_prefix('!') {
|
||||
let rest = rest.trim();
|
||||
@@ -762,9 +796,38 @@ fn handle_line(app: &App, session: &mut ShellSession, line: &str) -> Result<bool
|
||||
shell_command(session, rest.first().map(|s| s.as_str()));
|
||||
return Ok(false);
|
||||
}
|
||||
// Nushell-style directory listing (the system ls stays available as !ls).
|
||||
if cmd == "ls" {
|
||||
ls_command(app, rest)?;
|
||||
// Nushell-style directory listing (the system ls stays available as !ls,
|
||||
// and dir maps to the same table on Windows).
|
||||
if cmd == "ls" || (cmd == "dir" && cfg!(windows)) {
|
||||
let table = nav::ls_table(&ls_target(rest)?, app.color())?;
|
||||
show_table(app, last, table);
|
||||
return Ok(false);
|
||||
}
|
||||
if cmd == "ps" {
|
||||
show_table(app, last, ps::ps_table());
|
||||
return Ok(false);
|
||||
}
|
||||
if cmd == "where" {
|
||||
let base = last
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("'where' needs a table — run ls or ps first (e.g. ls | where size > 1mb)"))?;
|
||||
if rest.len() < 3 {
|
||||
anyhow::bail!("usage: where <column> <op> <value>");
|
||||
}
|
||||
let filtered =
|
||||
crate::tables::filter_where(&base, &rest[0], &rest[1], &rest[2..].join(" "))?;
|
||||
show_table(app, last, filtered);
|
||||
return Ok(false);
|
||||
}
|
||||
if cmd == "get" {
|
||||
let base = last
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow!("'get' needs a table — run ls or ps first (e.g. ls | get name)"))?;
|
||||
let field = rest
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("usage: get <column>"))?;
|
||||
let column = crate::tables::get_column(&base, field)?;
|
||||
show_table(app, last, column);
|
||||
return Ok(false);
|
||||
}
|
||||
// Everything that is not an am command is a system command.
|
||||
@@ -910,14 +973,76 @@ fn is_am_command(word: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Nushell-style listing of the current (or given) directory.
|
||||
fn ls_command(app: &App, rest: &[String]) -> Result<()> {
|
||||
let target = match rest.first() {
|
||||
/// Resolve the path argument of ls/dir (defaults to the current directory).
|
||||
fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
|
||||
Ok(match rest.first() {
|
||||
Some(p) => crate::config::expand_path(p),
|
||||
None => current_dir(),
|
||||
};
|
||||
print!("{}", nav::render_ls(&target, app.color())?);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Print a table and remember it as the last result (for where/get).
|
||||
fn show_table(app: &App, last: &mut Option<DataTable>, table: DataTable) {
|
||||
print!("{}", table.render(app.color()));
|
||||
*last = Some(table);
|
||||
}
|
||||
|
||||
/// Evaluate a Nushell-style pipeline: ls | ps | where | get, in order.
|
||||
fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<bool> {
|
||||
let parts: Vec<&str> = line
|
||||
.split('|')
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
let mut table: Option<DataTable> = None;
|
||||
for part in &parts {
|
||||
let tokens =
|
||||
shell_words::split(part).map_err(|e| anyhow!("cannot parse command: {e}"))?;
|
||||
if tokens.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let cmd = tokens[0].as_str();
|
||||
let rest = &tokens[1..];
|
||||
match cmd {
|
||||
"ls" => {
|
||||
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
|
||||
}
|
||||
"dir" if cfg!(windows) => {
|
||||
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
|
||||
}
|
||||
"ps" => {
|
||||
table = Some(ps::ps_table());
|
||||
}
|
||||
"where" => {
|
||||
let base = table
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("'where' needs a table before it"))?;
|
||||
if rest.len() < 3 {
|
||||
anyhow::bail!("usage: where <column> <op> <value>");
|
||||
}
|
||||
table = Some(crate::tables::filter_where(
|
||||
base,
|
||||
&rest[0],
|
||||
&rest[1],
|
||||
&rest[2..].join(" "),
|
||||
)?);
|
||||
}
|
||||
"get" => {
|
||||
let base = table
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("'get' needs a table before it"))?;
|
||||
let field = rest.first().ok_or_else(|| anyhow!("usage: get <column>"))?;
|
||||
table = Some(crate::tables::get_column(base, field)?);
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"'{other}' cannot be used in a pipeline — use ls, ps, where or get"
|
||||
),
|
||||
}
|
||||
}
|
||||
if let Some(t) = table {
|
||||
show_table(app, last, t);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Run one system command through the active shell (inherited stdio).
|
||||
@@ -999,14 +1124,21 @@ fn print_help() {
|
||||
println!(" export [--output FILE] backup config + state");
|
||||
println!(" import <file> restore a backup");
|
||||
println!(" shell [list|<name>] show or switch the system shell");
|
||||
println!(" ls [path] list files and directories (Nushell-style table)");
|
||||
println!(" ls [path] · dir [path] list files and directories (Nushell-style table)");
|
||||
println!(" ps list processes");
|
||||
println!(" where <col> <op> <val> filter the last table (ls | where size > 1mb)");
|
||||
println!(" get <col> select a column (ls | get name)");
|
||||
println!(" cd <dir> change directory — Tab completes folders");
|
||||
println!(" help | version | exit this help, the version, quit");
|
||||
println!();
|
||||
println!("pipelines:");
|
||||
println!(" ls | where size > 1mb | get name");
|
||||
println!(" ps | where name =~ am");
|
||||
println!();
|
||||
println!("system gateway:");
|
||||
println!(" any other input runs as a system command in the active shell");
|
||||
println!(" prefix with '!' to force system execution (e.g. '!ls', '!list')");
|
||||
println!(" Tab completes commands, agents, flags and paths (cycle with Tab)");
|
||||
println!(" Tab inserts the common prefix and lists candidates with descriptions");
|
||||
println!();
|
||||
println!("slash commands: /help · /version · /exit · /shell <name>");
|
||||
}
|
||||
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
//! Generic Nushell-style tables: render a boxed table, filter rows with
|
||||
//! where-like predicates and select a column with get.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use colored::Colorize;
|
||||
|
||||
/// One cell: the text to display plus a typed value for filtering
|
||||
/// (number, byte size, or plain string).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cell {
|
||||
pub text: String,
|
||||
pub num: Option<f64>,
|
||||
pub bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub fn str(s: impl Into<String>) -> Self {
|
||||
Cell {
|
||||
text: s.into(),
|
||||
num: None,
|
||||
bytes: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn num(n: f64, text: String) -> Self {
|
||||
Cell {
|
||||
text,
|
||||
num: Some(n),
|
||||
bytes: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes(b: u64, text: String) -> Self {
|
||||
Cell {
|
||||
text,
|
||||
num: None,
|
||||
bytes: Some(b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tabular result produced by ls, ps, where or get.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DataTable {
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<Vec<Cell>>,
|
||||
}
|
||||
|
||||
impl DataTable {
|
||||
pub fn column_index(&self, name: &str) -> Option<usize> {
|
||||
self.columns
|
||||
.iter()
|
||||
.position(|c| c.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Render the boxed Nushell-style table.
|
||||
pub fn render(&self, color: bool) -> String {
|
||||
let headers = self.columns.clone();
|
||||
// Column 0 is the row index; the data columns follow.
|
||||
let index_width = self.rows.len().to_string().len().max(1);
|
||||
let mut widths: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
|
||||
let mut cells: Vec<Vec<String>> = Vec::new();
|
||||
for (i, row) in self.rows.iter().enumerate() {
|
||||
let mut line = vec![i.to_string()];
|
||||
for cell in row {
|
||||
line.push(cell.text.clone());
|
||||
}
|
||||
for (c, cell) in line.iter().enumerate().skip(1) {
|
||||
widths[c - 1] = widths[c - 1].max(visible_len(cell));
|
||||
}
|
||||
cells.push(line);
|
||||
}
|
||||
let all_headers: Vec<String> = std::iter::once("#".to_string())
|
||||
.chain(headers.iter().cloned())
|
||||
.collect();
|
||||
let mut all_widths = vec![index_width];
|
||||
all_widths.extend(widths.iter().cloned());
|
||||
for (c, w) in all_widths.iter_mut().enumerate() {
|
||||
if c < all_headers.len() {
|
||||
*w = (*w).max(visible_len(&all_headers[c]));
|
||||
}
|
||||
}
|
||||
|
||||
let pad = |s: &str, w: usize| -> String {
|
||||
let n = visible_len(s);
|
||||
format!("{s}{}", " ".repeat(w.saturating_sub(n)))
|
||||
};
|
||||
let border = |left: char, mid: char, right: char| -> String {
|
||||
let mut line = String::new();
|
||||
line.push(left);
|
||||
for (i, w) in all_widths.iter().enumerate() {
|
||||
if i > 0 {
|
||||
line.push(mid);
|
||||
}
|
||||
line.push_str(&"─".repeat(w + 2));
|
||||
}
|
||||
line.push(right);
|
||||
line
|
||||
};
|
||||
let row_line = |row: &[String], bold: bool| -> String {
|
||||
let mut line = String::from("│");
|
||||
for (i, c) in row.iter().enumerate() {
|
||||
line.push(' ');
|
||||
let cell = pad(c, all_widths[i]);
|
||||
let rendered = if bold {
|
||||
cell.bold().to_string()
|
||||
} else {
|
||||
cell
|
||||
};
|
||||
line.push_str(&rendered);
|
||||
line.push(' ');
|
||||
line.push('│');
|
||||
}
|
||||
line
|
||||
};
|
||||
let dim = |s: &str| -> String {
|
||||
if color {
|
||||
s.dimmed().to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str(&dim(&border('╭', '┬', '╮')));
|
||||
out.push('\n');
|
||||
out.push_str(&row_line(&all_headers, true));
|
||||
out.push('\n');
|
||||
out.push_str(&dim(&border('├', '┼', '┤')));
|
||||
out.push('\n');
|
||||
for line in &cells {
|
||||
out.push_str(&row_line(line, false));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&dim(&border('╰', '┴', '╯')));
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a where value: "1mb"/"512kb"/"2gb" become byte sizes, numbers
|
||||
/// become numbers, everything else is a string.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum WhereValue {
|
||||
Num(f64),
|
||||
Bytes(u64),
|
||||
Str(String),
|
||||
}
|
||||
|
||||
fn parse_value(raw: &str) -> WhereValue {
|
||||
let lower = raw.trim().to_lowercase();
|
||||
for (suffix, mult) in [("gb", 1024u64.pow(3)), ("mb", 1024u64.pow(2)), ("kb", 1024)] {
|
||||
if let Some(n) = lower.strip_suffix(suffix) {
|
||||
if let Ok(v) = n.trim().parse::<f64>() {
|
||||
return WhereValue::Bytes((v * mult as f64) as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(v) = raw.trim().parse::<f64>() {
|
||||
return WhereValue::Num(v);
|
||||
}
|
||||
WhereValue::Str(raw.trim().to_string())
|
||||
}
|
||||
|
||||
fn num_cmp(a: f64, b: f64, op: &str) -> bool {
|
||||
match op {
|
||||
">" => a > b,
|
||||
"<" => a < b,
|
||||
">=" => a >= b,
|
||||
"<=" => a <= b,
|
||||
"==" => (a - b).abs() < f64::EPSILON,
|
||||
"!=" => (a - b).abs() >= f64::EPSILON,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter the rows of a table: where <field> <op> <value>.
|
||||
pub fn filter_where(table: &DataTable, field: &str, op: &str, value: &str) -> Result<DataTable> {
|
||||
let Some(idx) = table.column_index(field) else {
|
||||
bail!(
|
||||
"unknown column '{field}' — available: {}",
|
||||
table.columns.join(", ")
|
||||
);
|
||||
};
|
||||
let val = parse_value(value);
|
||||
let supported = matches!(op, ">" | "<" | ">=" | "<=" | "==" | "!=" | "=~");
|
||||
if !supported {
|
||||
bail!("unknown operator '{op}' — use > < >= <= == != =~");
|
||||
}
|
||||
let mut out = table.clone();
|
||||
out.rows.retain(|row| match row.get(idx) {
|
||||
None => false,
|
||||
Some(cell) => match &val {
|
||||
WhereValue::Num(n) => match cell.num {
|
||||
Some(v) => num_cmp(v, *n, op),
|
||||
None => false,
|
||||
},
|
||||
WhereValue::Bytes(b) => match cell.bytes {
|
||||
Some(v) => num_cmp(v as f64, *b as f64, op),
|
||||
None => false,
|
||||
},
|
||||
WhereValue::Str(s) => {
|
||||
let text = strip_ansi(&cell.text);
|
||||
match op {
|
||||
"==" => text.eq_ignore_ascii_case(s),
|
||||
"!=" => !text.eq_ignore_ascii_case(s),
|
||||
"=~" => text.to_lowercase().contains(&s.to_lowercase()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Select one column: get <column>.
|
||||
pub fn get_column(table: &DataTable, field: &str) -> Result<DataTable> {
|
||||
let Some(idx) = table.column_index(field) else {
|
||||
bail!(
|
||||
"unknown column '{field}' — available: {}",
|
||||
table.columns.join(", ")
|
||||
);
|
||||
};
|
||||
Ok(DataTable {
|
||||
columns: vec![field.to_string()],
|
||||
rows: table
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| vec![row[idx].clone()])
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn strip_ansi(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\u{1b}' {
|
||||
for n in chars.by_ref() {
|
||||
if n == 'm' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn visible_len(s: &str) -> usize {
|
||||
strip_ansi(s).chars().count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> DataTable {
|
||||
DataTable {
|
||||
columns: vec!["name".into(), "type".into(), "size".into()],
|
||||
rows: vec![
|
||||
vec![
|
||||
Cell::str("src"),
|
||||
Cell::str("dir"),
|
||||
Cell::bytes(2048, "2.0 KB".into()),
|
||||
],
|
||||
vec![
|
||||
Cell::str("am.exe"),
|
||||
Cell::str("file"),
|
||||
Cell::bytes(5_000_000, "4.8 MB".into()),
|
||||
],
|
||||
vec![
|
||||
Cell::str("readme.md"),
|
||||
Cell::str("file"),
|
||||
Cell::bytes(512, "512 B".into()),
|
||||
],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_numeric_and_size() {
|
||||
let t = sample();
|
||||
let big = filter_where(&t, "size", ">", "1mb").unwrap();
|
||||
assert_eq!(big.rows.len(), 1);
|
||||
assert_eq!(big.rows[0][0].text, "am.exe");
|
||||
let eq = filter_where(&t, "type", "==", "dir").unwrap();
|
||||
assert_eq!(eq.rows.len(), 1);
|
||||
let contains = filter_where(&t, "name", "=~", "read").unwrap();
|
||||
assert_eq!(contains.rows.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_selects_column() {
|
||||
let t = sample();
|
||||
let names = get_column(&t, "name").unwrap();
|
||||
assert_eq!(names.columns, vec!["name".to_string()]);
|
||||
assert_eq!(names.rows.len(), 3);
|
||||
assert_eq!(names.rows[1][0].text, "am.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_column_errors() {
|
||||
let t = sample();
|
||||
assert!(filter_where(&t, "nope", "==", "x").is_err());
|
||||
assert!(get_column(&t, "nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_where_values() {
|
||||
assert_eq!(parse_value("1mb"), WhereValue::Bytes(1024 * 1024));
|
||||
assert_eq!(parse_value("512kb"), WhereValue::Bytes(512 * 1024));
|
||||
assert_eq!(parse_value("3.5"), WhereValue::Num(3.5));
|
||||
assert_eq!(parse_value("hello"), WhereValue::Str("hello".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_is_aligned() {
|
||||
let t = sample();
|
||||
let text = t.render(false);
|
||||
assert!(text.contains("╭"));
|
||||
assert!(text.contains("name"));
|
||||
assert!(text.contains("am.exe"));
|
||||
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
|
||||
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user