Compare commits

...

1082 Commits

Author SHA1 Message Date
8379ae2136 refactor: rename plugin features with type prefix for consistency
- Plugin features now use type_ prefix (meta_magic, filter_grep, etc.)
- Added meta_all_musl and filter_all_musl for MUSL-compatible builds
- grep filter plugin made optional via filter_grep feature flag
- Removed regex crate from grep-related code, uses strip_prefix instead
- Updated CHANGELOG.md with breaking change documentation
2026-03-21 17:36:29 -03:00
12de215527 feat: feature-gate CLI args by server/client features
- CLI now shows only relevant options: --server and --server-* args
  hidden when built without 'server' feature; --client-* args hidden
  without 'client' feature. Run --help only displays applicable options.
- Removed verbose 'conflicts_with_all' from all mode args — clap's
  implicit group("mode") already enforces mutual exclusivity.
- 'server' feature now includes TLS/HTTPS by default (axum-server);
  'tls' feature removed. rustls already available via client/ureq.
- Gated KeepModes::Server, server mode detection, and server-password
  validation in main.rs.
- Gated server arg reads in config.rs.
- Removed redundant #[cfg(feature = "tls")] guards from server/mod.rs.
- Gated resolve_item_id/resolve_item_ids helpers in common.rs.
- All 4 feature combinations (server+client, server-only, client-only,
  neither) compile and pass tests.
2026-03-21 16:26:27 -03:00
e2cb36d2a8 feat(server): add file_size to API ItemInfo response 2026-03-21 14:03:58 -03:00
0004324301 perf: pre-allocate status info collections with known capacities 2026-03-21 13:54:37 -03:00
b3edfe7de6 chore: code review cleanup — fixes, deps, docs
Fixed:
- CLI help typo: "metatdata" -> "metadata"
- Filter buffer OOM: check size before loading into memory

Changed:
- #[inline] on HTML escape helpers for hot path performance
- Replaced once_cell and lazy_static with std::sync::LazyLock
- Removed unused once_cell and lazy_static crate dependencies

Refactored:
- Added module-level doc to services/ module

Documentation:
- README.md: zstd is native not external, "none" -> "raw"
- DESIGN.md: current schema and meta plugins section
- CHANGELOG.md: Unreleased section populated
2026-03-21 11:44:37 -03:00
ab2fb07505 docs: add changelog update instructions to AGENTS.md 2026-03-21 10:56:43 -03:00
547f0b5d11 docs: add CHANGELOG.md following Keep a Changelog format 2026-03-21 10:55:16 -03:00
30d7836bcf refactor: deduplicate ItemInfo, improve error handling, fix pre-existing bugs
- Move ItemInfo to services/types.rs for sharing between client and server
- Replace .expect() in compression_service with proper error handling
- Add CoreError::PayloadTooLarge variant for semantic error handling
- Export CoreError from lib.rs for library users
- Unify get_item_meta_name/value to take &str instead of String
- Extract item_path() helper in ItemService to reduce duplication
- Add warning logs for silent errors in list.rs
- Fix pre-existing borrow errors: tx moved in export handler,
  item_with_meta partial move in TryFrom implementation
- Fix unused data_dir variables in server code
2026-03-21 10:43:26 -03:00
2cfee5075e fix: panic guards, dedup, and unsafe documentation
- diff.rs: graceful error instead of expect() on item ID in spawned thread
- common.rs: lazy_static regex, avoid unwrap on regex captures
- db.rs: ok_or_else guard on item.id in delete_item
- list/get/info/export/client/list: use settings.meta_filter() helper
- item_service.rs: expect() on meta lock instead of silent swallow
- filter_plugin/mod.rs: extract parse_encoding_option() helper
- main.rs: document unsafe libc::umask block with safety rationale
2026-03-20 17:17:58 -03:00
52e9787edb refactor: deduplicate filter plugins, extract helpers across codebase
Bug fixes:
- client: add error field to ApiResponse to avoid swallowing server errors
- args/config: fix list_format default mismatch (5 vs 7 columns)
- client: url-encode size param in set_item_size

Dedup - filter plugins:
- Extract count_option() and pattern_option() helpers, replace 7 identical options()
- Add #[derive(Clone)] to all filter structs; remove verbose clone_box() impls
- Simplify FilterChain clone() and impl Clone for Box<dyn FilterPlugin>
- Add filter_clone_box! macro for future use
- Fix doctest example missing clone_box

Dedup - server API:
- Extract spawn_body_reader() with LimitBehavior enum for body streaming
- Extract check_binary_content() helper
- Extract stream_with_offset_and_length() helper
- Extract generate_status() helper in status.rs
- Extract append_query_params() helper in client.rs

Dedup - other:
- Extract yaml_value_to_string() in meta_plugin/mod.rs
- Extract item_from_row() in db.rs
- Delete unused DisplayListItem struct

Misc:
- Remove duplicate doc comment in compression_service.rs
2026-03-20 15:54:33 -03:00
00be72f3d0 refactor: rename size to uncompressed_size, add compressed_size and closed columns
Schema changes:
- Rename items.size to items.uncompressed_size for clarity
- Add compressed_size (INTEGER NULL) - tracks compressed file size on disk
- Add closed (BOOLEAN NOT NULL DEFAULT 1) - tracks whether item is fully written
- Existing items default to closed=true via migration

Lifecycle:
- Items created with closed=false, set to true on successful save/import
- Compressed size captured via fs::metadata() after compression writer closes
- Truncated uploads (413) get compressed_size set, closed=true, uncompressed_size=None
- Update command now backfills both uncompressed_size and compressed_size

Also includes bug fixes and dedup from prior review:
- Fix stream_raw_content_response using uncompressed_size for raw byte Content-Length
- ApiResponse::ok()/empty() constructors, TryFrom<ItemWithMeta> for ItemInfo
- tag_names() method on ItemWithMeta, meta_filter() on Settings
- Fix .unwrap() panics in compression engine Read/Write impls
- Fix TOCTOU race in stream_raw_content_response (now uses compressed_size)
- Fix swallowed write errors in meta plugins (digest, magic_file, exec)
- Fix term::stderr().unwrap() panic in item_service
- Deduplicate ItemService::new() calls across 20 API handlers
- ImportMeta supports #[serde(alias = "size")] for backward compat

All 75 tests, 67 doc tests pass. Clippy clean.
2026-03-18 10:58:26 -03:00
49793a0f94 feat: add streaming tar export/import and rename "none" to "raw"
- Add streaming tar-based export (--export produces .keep.tar)
- Add streaming tar import (--import reads .keep.tar archives)
- Add server endpoints GET /api/export and POST /api/import
- Rename CompressionType::None to CompressionType::Raw with "none" as alias
- Add DB migration to update existing "none" compression values to "raw"
- Fix export endpoint to propagate errors to client instead of swallowing
- Fix import endpoint to return 413 on max_body_size instead of truncating

Export streams items as tar archives without loading entire files into memory.
Import extracts items with new IDs, preserving original order. Both work
locally and via client/server mode.

Co-Authored-By: opencode <noreply@opencode.ai>
2026-03-17 21:24:39 -03:00
074ba64805 feat: allow --list to accept item IDs for filtering
- Local and client/server modes now support ID-based filtering
- keep -l 1 2 3 lists specific items by ID
- keep -l --ids-only 1 2 3 outputs just those IDs
- Server API adds optional 'ids' query parameter to GET /api/item/
- KeepClient.list_items gains ids parameter
2026-03-17 17:56:35 -03:00
02f0c8d453 fix: use XDG config directory for default config file location
Changes from manual HOME/.config/keep/config.yml construction to
dirs::config_dir(), which respects XDG_CONFIG_HOME.
2026-03-17 16:07:13 -03:00
c29e37c03e fix: use XDG data directory as default storage location
Changes default from ~/.keep to /keep
(e.g. ~/.local/share/keep on Linux). Uses dirs::data_dir() which
respects XDG_DATA_HOME environment variable.
2026-03-17 15:37:25 -03:00
28c3deaeca fix: expand tilde (~) in config file paths to home directory
Applies to dir, import_data_file, and all server certificate/secret file
paths. Uses existing dirs crate for home directory resolution.
2026-03-17 15:32:30 -03:00
cb56a398fa feat: add --ids-only flag to --list mode for scripting
Outputs one ID per line with no header. Errors if used with any mode
other than --list. Works with both local and client (remote) list.
2026-03-17 15:04:10 -03:00
2452da52ef chore: add license, repository, keywords, and rust-version to Cargo.toml 2026-03-17 14:50:45 -03:00
6347427536 chore: remove bin/keep binary from tracking, add bin/ to gitignore 2026-03-17 14:47:57 -03:00
a8759c4b83 feat: add infer and tree_magic_mini meta plugins, make zstd internal by default
- Add infer crate as meta plugin for MIME type detection
- Add tree_magic_mini crate as alternative meta plugin for MIME type detection
- Add zstd, infer, tree_magic_mini to default features
- Fix static build script to use musl target instead of glibc+crt-static
- Remove hardcoded shell list from --generate-completion help text
- Fix update() in both new plugins to emit MIME metadata when buffer fills
2026-03-17 14:46:51 -03:00
a90c19efc1 feat: add native zstd compression plugin and deduplicate shared compression/meta utilities
- Add zstd crate (v0.13) with native Rust compression engine (level 3)
- Gate behind 'zstd' feature flag, fall back to program-based when disabled
- Extract CompressionService::decompressing_reader/compressing_writer with zstd support
- Extract MetaService::with_collector() to eliminate Arc<Mutex<Vec>> boilerplate
- Extract read_with_bounds() helper for skip+read pattern
- Add input validation for mutually exclusive --id and --tags flags
- Add zstd round-trip tests
2026-03-16 20:03:30 -03:00
35ee71c3cf feat: add export/import modes, unify service layer, fix binary detection
Export/import:
- Add --export and --import modes for both local and client paths
- Use strfmt crate for --export-filename-format templates ({id}, {tags}, {ts}, {compression})
- Import preserves original timestamps via server ?ts= param
- --import-data-file for file-based import; stdin fallback streams with PIPESIZE buffers

Service unification:
- Merge SyncDataService unique methods into ItemService (delete_item now returns Result<Item>)
- Delete AsyncDataService, AsyncItemService, DataService trait (dead code / async-blocking anti-pattern)
- All server handlers use spawn_blocking + ItemService directly
- Extract shared types (ExportMeta, ImportMeta) and helpers (resolve_item_id(s), check_binary_tty)

Binary detection fix:
- Replace broken metadata.get("map") + is_binary(&[]) with actual content sampling
- Both as_meta and allow_binary paths read PIPESIZE sample before deciding
- Never load entire item into memory for binary check

Other fixes:
- Fix lock consistency: all handlers use blocking_lock() in spawn_blocking (no mixed lock().await)
- Use ISO 8601 format for {ts} in export filenames
- Fix resolve_item_ids returning only 1 item for tag lookups
- Fix client get.rs triple-buffering and export.rs whole-file buffering
- Add KeepClient::get_item_content_stream() for streaming reads
- Pass all clippy --features server lints (Path vs PathBuf, &mut conn, etc.)
2026-03-16 08:43:26 -03:00
0a3d61a875 fix: client save with --compression none stored lz4 instead of none
- server_compress was true when compression_type=None, telling server to
  recompress with its default (lz4) instead of storing raw
- compression_type query param was only sent when !server_compress,
  so 'none' was never sent to server
- Fix: server_compress always false in client mode (client handles all
  compression), compression_type always sent to server

Tested: save/get/list/info/filters/delete for lz4, none, gzip on both
local and client/server modes. All operations produce matching results.
2026-03-15 12:46:29 -03:00
eca17b36ee fix: client save logs item ID early, stores compression via proper field and size via update endpoint
- Client save now logs 'New item: {id}' immediately after server response
- Compression type sent as query param, stored in DB compression field (not _client_compression metadata)
- Client set_item_size() sends uncompressed size via POST /api/item/{id}/update?size=N
- Server raw content GET uses actual file size for Content-Length (not uncompressed item.size)
- Removed _client_compression metadata hack from client save and get
- Fixed server handle_update_item to support size-only updates
- Fixed clippy: collapsible_if, too_many_arguments, unnecessary mut refs
- Fixed ListItemsQuery doctest missing meta field
2026-03-15 10:14:55 -03:00
5bad7ac7a6 refactor: decouple meta plugins from DB via SaveMetaFn callback, extract shared utilities
- Add SaveMetaFn callback pattern: meta plugins receive a closure instead of
  &Connection, enabling the same plugin code to work in local, client, and
  server contexts (collect-to-Vec, collect-to-HashMap, or direct DB write)
- Client save now runs meta plugins locally during streaming (smart client
  sets meta=false, server skips its own plugins)
- Add POST /api/item/{id}/update endpoint for re-running plugins on stored
  content without downloading compressed data
- Add client update mode (--update with --meta-plugin flags)
- Extract shared utilities: stream_copy, print_serialized, build_path_table,
  ensure_default_tag to reduce duplication across modes
- Add upsert_tag for idempotent tag addition (INSERT OR IGNORE)
- Add warn logging on save_meta lock failure in BaseMetaPlugin and MetaService
2026-03-14 22:36:59 -03:00
fdc5f1d744 fix: client --list uses list_format from config like local mode
Move apply_color/apply_table_attribute to common.rs for sharing.
Add render_list_table_with_format() that takes ColumnConfig slice
and pre-computed row values. Client list now renders columns based
on settings.list_format, showing empty for columns where server
data is unavailable (e.g. text_line_count, token_count).
2026-03-14 20:01:58 -03:00
f5bae46620 fix: all tables respect table_config from settings
Extract shared render_item_info_table() and render_list_table() in
modes/common.rs. Update client/info, client/list, client/status,
info, status, and status_plugins to use create_table_with_config
with settings.table_config instead of hardcoded presets.

Previously only local --list used table_config; all other tables
(client modes, status, status-plugins) ignored it.
2026-03-14 19:49:31 -03:00
0bc8d9c909 fix: surface server error in get_status and trim table output
- Include error field in get_status() ApiResponse so server error
  messages are surfaced instead of generic 'No status data returned'
- Use trim_lines_end() on table output to match local mode formatting
2026-03-14 19:32:39 -03:00
1a942b4d23 fix: format client --status output as tables instead of raw JSON
Change client get_status() to return StatusInfo struct instead of
serde_json::Value, then render paths, meta plugins, and compression
tables matching the local mode's output style.
2026-03-14 19:25:53 -03:00
886ac98b21 fix: URL-encode query params in client and pass --meta to server on save
- URL-encode all query parameter keys and values in get_json_with_query
  and post_stream. Previously raw JSON like {"project":"alpha"} was
  sent unencoded, causing 'invalid uri character' errors.
- Pass settings.meta (key=value pairs) from client save to server as
  metadata. Previously always passed empty HashMap, so --meta was
  silently ignored in client save mode.
2026-03-14 19:16:39 -03:00
0658d8378f fix: group all server options under Server Options help heading
The --server-password, --server-password-hash, --server-username,
--server-jwt-secret, --server-jwt-secret-file, and --server-max-body-size
options were appearing in the generic Options section instead of the
Server Options section.
2026-03-14 18:56:32 -03:00
ffe71440d9 fix: use explicit snake_case serialization for CompressionType
Per project convention, enum string representations should use
snake_case. Use explicit strum serialize attributes instead of
serialize_all to avoid incorrect splitting of acronyms like
GZip → g_zip and ZStd → z_std.
2026-03-14 18:26:58 -03:00
8acbd34150 fix: add --meta filtering support to client/server list mode
Plumb metadata filter from client CLI through the HTTP API to the
server's data_service.list_items(). The server accepts a JSON-encoded
meta query parameter where null values mean 'key exists' and string
values mean 'exact match'.

Also fix LZ4 compression round-trip for client mode:
- Explicit flush FrameEncoder before drop to avoid sending only the
  frame header when compress=false
- Send _client_compression metadata so client knows actual compression
  on retrieval (server records compression=None when compress=false)
- Use FrameDecoder (frame format) instead of decompress_size_prepended
  (size-prepended format) to match server storage format
2026-03-14 18:22:07 -03:00
f2d93a2812 fix: skip_lines/skip_bytes filters producing empty output on large files
FilteringReader::read() returned Ok(0) (EOF) when a filter consumed a
chunk without producing output. Filters like skip_lines need to see
multiple chunks before outputting anything — returning 0 prematurely
truncated the stream. Loop until the filter produces output or the
underlying reader is truly exhausted.
2026-03-14 16:20:30 -03:00
0af74000d2 fix: eliminate unsafe code via nix, command-fds, and thread-local cookie
Replace 4 unsafe sites with safe wrappers:

- libc::pipe2 → nix::unistd::pipe2 (safe OwnedFd return)
- File::from_raw_fd → File::from(OwnedFd) (safe ownership transfer)
- unsafe impl Send for SendCookie → thread_local! lazy Cookie
  (each thread gets its own independent Cookie, no Send needed)
- pre_exec + libc::fcntl → command-fds crate fd_mappings()
  (handles CLOEXEC clearing safely, also fixes potential fd leak
  on spawn failure via OwnedFd RAII)

Only libc::umask remains as a single unavoidable unsafe site
(no safe Rust wrapper exists for the umask syscall).

Also updates AGENTS.md to remove stale SendCookie exception.
2026-03-14 16:01:54 -03:00
9a1e23e85f fix: use tempdir for db doctests instead of project root
All 27 doctests in db.rs wrote keep.db to the project root via
PathBuf::from("keep.db"). Now use tempfile::tempdir() so the
database is created in a temp directory and cleaned up automatically.
2026-03-14 15:10:47 -03:00
b3ca673b52 feat: add --update mode, --meta/--meta-plugin flags, streaming diff
- Add --update mode to modify tags and metadata for existing items by ID
- Add --meta key=value flag to set metadata during save/update
- Add --meta key (bare) to delete metadata keys or filter by existence
- Add --meta-plugin/-M name:{json} flag for plugin options via CLI
- Env meta plugin now uses options from --meta-plugin instead of only env vars
- Stream decompressed content to diff via /dev/fd pipes (no temp files)
- Wire --list-format CLI arg to settings (was parsed but ignored)
- Allow --info to accept tags (was restricted to numeric IDs only)
- Change DB meta filtering to HashMap<String, Option<String>> for exact match + key existence
- Fix fcntl error checking in diff pre_exec
- Fix README inaccuracies (delete by tag, nonexistent --digest flag, meta plugin key names)
2026-03-14 15:02:16 -03:00
4b51825917 docs: document default mode shortcuts for save and get
- Quick Start: show bare keep <tag> (save) and keep <#> (get) shortcuts
- Save Mode: note that --save is optional when piping content
- Get Mode: clarify that only numeric IDs default to Get mode;
  fix incorrect keep <tag> example that would actually save
2026-03-14 11:48:37 -03:00
2ffa2a977a feat: add shell profiles for zsh, sh, csh/tcsh
- profile.bash: simplified preexec_init (early return), extracted
  ___keep_complete helper for @/@@ completion wrappers
- profile.zsh: add-zsh-hook preexec, wrapper function, @/@@ aliases,
  completions via compdef
- profile.sh: POSIX-compatible for sh/dash/ksh. Wrapper function,
  @/@@ aliases. No preexec or completions.
- profile.csh: alias-based keep wrapper, @/@@ aliases. No preexec
  or completions.
- modulefile: adds KEEP_SH_PROFILE, KEEP_ZSH_PROFILE, KEEP_CSH_PROFILE
- README: updated Shell Integration table and Shell Completion section
2026-03-14 11:36:29 -03:00
1a8ed56b68 feat: add --generate-completion for shell tab completion
- Add clap_complete dependency for bash/zsh/fish/elvish/powershell
- Add --generate-completion <shell> flag that prints completion script to stdout
- profile.bash sources completions via command keep --generate-completion bash
- @ and @@ aliases get completions via wrapper functions that delegate to _keep
- README updated with Shell Completion section
2026-03-14 11:02:38 -03:00
158bf50864 docs: add environment modulefile instructions to README 2026-03-14 10:36:57 -03:00
17be6abaab refactor: streaming, security hardening, and MCP removal
Major overhaul of server architecture and security posture:

- Streaming: Unified all I/O through PIPESIZE (8192-byte) buffers.
  POST bodies stream via MpscReader through the save pipeline. GET
  content streams from disk via decompression to client. Removed
  save_item_with_reader, get_item_content_info, ChannelReader.
  413 responses keep partial items (nonfatal by design).

- Security: XSS protection in all HTML pages via html_escape crate.
  Security headers middleware (nosniff, frame deny, referrer policy).
  CORS tightened to explicit headers. Input validation for tags
  (256 chars), metadata (128/4096), pagination (10k cap). Config
  file reads use from_utf8_lossy. Generic error messages in HTML.
  Diff endpoint has 10 MB per-item cap. max_body_size config option.

- Panics eliminated: Path unwraps → proper error propagation.
  Mutex unwraps → map_err (registries) / expect with message (local).

- MCP removed: Deleted all MCP code, rmcp dependency, mcp feature.

- Docs: Updated README, DESIGN, AGENTS to reflect all changes.
2026-03-14 00:03:42 -03:00
560ba6e20c fix: count_bounded error counting, clippy if-let, auth test dedup, doc tests
- count_bounded: break on iterator error instead of counting errors as tokens
- collapse nested if-let chains with let-chains in auth middleware
- document JWT/Basic Auth as mutually exclusive
- TailTokensFilter::clone uses empty buffer (always pre-filter)
- fix 9 broken doc examples in server/common.rs
- remove 7 duplicate auth tests from auth.rs (covered by auth_tests.rs)
2026-03-13 22:04:38 -03:00
a07bb6b350 feat: plugin-declared parallel execution, switch to env_logger, update deps
Parallel execution (opt-in via MetaPlugin::parallel_safe):
- Add Send bound to MetaPlugin, parallel_safe() method (default false)
- Override to true in digest, tokens, exec, magic_file plugins
- MetaService: std::thread::scope for initialize_plugins and process_chunk
- Extract plugins via NullMetaPlugin sentinel + std::mem::replace (no unsafe)
- Panic tracking: join errors logged, NullMetaPlugin restored and finalized
- MetaPluginExec: Box<dyn Write> -> Box<dyn Write + Send>
- SendCookie wrapper for libmagic Cookie with unsafe impl Send

Logging (stderrlog -> env_logger):
- Custom format: [SSSSSS.mmm] LEVEL [module:] message (time-since-start ms)
- Default level: Warn (matches previous behavior)
- -v: Debug, -vv+: Trace, -q: off
- -vv+ shows module path

Maintenance:
- Bump deps: thiserror 2.0, config 0.15, dns-lookup 3.0, lz4_flex 0.12,
  ringbuf 0.4, rand 0.9, lazy_static 1.5, env_logger 0.11
- Update Cargo.lock (186 transitive packages)
- Clippy fixes: is_multiple_of, to_string_in_format_args, collapsible_if
- Fix double-counting bug in TokensMetaPlugin::update
- Fix schema description using plugin.description()

Co-Authored-By: opencode <noreply@opencode.ai>
2026-03-13 21:49:51 -03:00
e7d8a83369 feat: add plugin schema system, tokenizer cache, and config validation
- Add plugin schema types and runtime discovery for meta/filter plugins
- Rewrite --generate-config to use schema system instead of hardcoded types
- Add Settings::validate_config() for startup validation
- Cache tokenizer instances via static Lazy to avoid repeated BPE loading
- Add split_by_token_iter() and count_bounded() to Tokenizer
- Fix double-counting bug in TokensMetaPlugin when buffer < max_buffer_size
- Eliminate unnecessary allocations in token count methods
- Refactor token filters: remove Option<Tokenizer>, use iterator API
- Fix TailTokensFilter correctness: unbounded buffer instead of ring buffer
- Add encoding option to all token filters
- Add description() to MetaPlugin and FilterPlugin traits
- Fix unused_mut warning in compression engine (feature-gated code)

Co-Authored-By: code-review-bot <noreply@anthropic.com>
2026-03-13 20:23:17 -03:00
914190e119 feat: add LLM token counting meta plugin and token filters
Add tiktoken-based token counting via new 'tokens' feature flag.

New components:
- Shared tokenizer module wrapping tiktoken CoreBPE (cl100k_base, o200k_base)
- TokensMetaPlugin: streaming token counter, tokenizes each chunk independently
- head_tokens(N): stream first N tokens, split at exact boundary when mid-chunk
- skip_tokens(N): skip first N tokens, stream the rest
- tail_tokens(N): bounded ring buffer (~16KB), outputs last N tokens at finalize

All filters are fully streaming — no full-stream buffering.
Meta plugin accuracy: exact for normal text, ±1-2 tokens if long whitespace
sequence spans a chunk boundary.

Also: add 'client' and 'tokens' to default features, add curl to Dockerfile builder stage.
2026-03-13 16:48:31 -03:00
e672ec751e feat: add JWT auth, configurable username, switch password auth to Basic
Add server-side JWT authentication with permission-based access control
(read/write/delete claims). Password authentication now uses HTTP Basic
auth only (replacing Bearer). Add configurable username for both server
and client (--server-username/--client-username, defaults to "keep").

JWT secret supports file-based loading via --server-jwt-secret-file for
Docker secrets. OPTIONS preflight requests bypass auth. HEAD mapped to
read permission.

Co-Authored-By: opencode <noreply@opencode.ai>
2026-03-13 13:56:35 -03:00
af1e0ca570 feat: expand Docker build to all features, add docker-compose.yml
- Build with server, mcp, swagger, client, tls features (all except magic)
- Add KEEP_* environment variable documentation and defaults
- Copy CA certificates for HTTPS client support in scratch image
- Add docker-compose.yml with keep-data and keep-config volumes
2026-03-13 10:08:28 -03:00
d5d58bc52c feat: add lz4 command fallback, remove unused magic.rs
- Add program-based lz4 command fallback when lz4 feature is disabled
- Feature-gate lz4.rs and lz4 tests to compile without lz4_flex
- Delete legacy magic.rs (unused, no feature gating, superseded by magic_file.rs)
2026-03-13 08:51:10 -03:00
b166477202 fix: harden security, eliminate panics, remove dead code, add Dockerfile
Security:
- Use constant-time password comparison (subtle crate) to prevent timing attacks
- Replace permissive CORS with configurable origin-restricted CORS
- Add TLS warning when password auth is used without HTTPS

Bug fixes:
- Convert MetaPlugin panics to anyhow::Result (get_meta_plugin, outputs_mut, options_mut)
- Replace item.id.unwrap() with proper error handling across 15 call sites
- Fix panic on unknown column type in list mode
- Fix conflicting PIPESIZE constant (was 8192 vs 65536, now unified to 8192)
- Add 256MB filter chain buffer limit to prevent OOM
- Gracefully skip unregistered plugins instead of panicking

Dead code removal:
- Delete unused filter parser files (filter_parser.rs, filter.pest, parser/ module)
- ~260 lines of dead PEG parser code removed

Code consolidation:
- Add is_content_binary_from_metadata() helper (was duplicated in 4 places)
- Simplify save_item_raw() to delegate to save_item_raw_streaming() (~90 lines removed)

Incomplete features:
- Populate filter_plugins in status output from global registry
- Add FallbackMagicFileMetaPlugin (was referenced but never implemented)
- Document init_plugins() as intentional no-op

Infrastructure:
- Add Dockerfile (static musl binary on scratch, 4.8MB)
- Add .dockerignore
- Add cors_origin to ServerConfig and config.rs
2026-03-13 07:57:36 -03:00
bee980605f feat: add HTTPS/TLS server support via rustls
Add optional TLS support for the server using axum-server with the
tls-rustls feature. When --server-cert and --server-key are provided
(and tls feature is enabled), the server binds with TLS instead of
plain HTTP.

Changes:
- Add axum-server dependency with optional tls-rustls feature
- New 'tls' feature flag (independent of 'server')
- --server-cert/--server-key CLI args gated behind tls feature
- ServerConfig extended with cert_file/key_file fields
- Conditional TLS/HTTP binding in server mod.rs
- Fix PathBuf::to_str().unwrap() panic risk -> to_string_lossy()
- Update README.md and DESIGN.md with TLS documentation
2026-03-12 22:18:42 -03:00
237a581429 fix: add server streaming support, fix pre-existing compilation errors
Server changes for client mode streaming:
- POST /api/item/ now streams body via async channel → ChannelReader
  → save_item_raw_streaming when compress=false or meta=false
- Add POST /api/item/{id}/meta endpoint for client-side metadata
- Add save_item_raw_streaming<R: Read> to SyncDataService
- Add add_item_meta to AsyncDataService

Fix pre-existing issues that were hidden behind swagger cfg gate:
- Remove #[cfg(feature = "swagger")] from item module so it compiles
  with just the server feature
- Fix parse_comma_tags usage (returns Vec, not Result)
- Fix TextDiff temporary value lifetime issue
- Fix io::Error::new → io::Error::other
- Fix ok_or_else → ok_or for Copy types
- Inline format args throughout server code
- Fix empty line after doc comment in pages.rs
- Add cfg_attr for unused_mut where mcp feature gates mutation
- Add type_complexity allow on create_auth_middleware
- Distinguish task error vs save error in spawn_blocking handlers

Co-Authored-By: andrew/openrouter/hunter-alpha <noreply@opencode.ai>
2026-03-12 18:02:56 -03:00
c5529bedbf feat: add client mode with streaming support
Add client mode enabling the keep CLI to connect to a remote keep
server over HTTP. Local plugins (compression, meta, filters) run on
the client; the server stores/retrieves binary blobs.

Architecture:
- Client save uses 3-thread streaming pipeline: reader thread (stdin
  → tee/stdout → hash → compress), OS pipe, streamer thread (pipe →
  chunked HTTP POST). Memory usage is O(PIPESIZE) regardless of data
  size.
- Server accepts compress=false, meta=false, decompress=false query
  params for granular control of server-side processing.
- Streaming body handling on server via async channel → sync reader
  bridge (ChannelReader).

Key additions:
- src/client.rs: KeepClient with post_stream() for chunked upload
- src/modes/client/: save, get, list, info, delete, diff, status
- --client-url / KEEP_CLIENT_URL configuration
- --client-password / KEEP_CLIENT_PASSWORD for auth
- os_pipe dependency for zero-copy pipe streaming

Co-Authored-By: andrew/openrouter/hunter-alpha <noreply@opencode.ai>
2026-03-12 18:01:36 -03:00
d2581358e9 docs: rewrite README, add LICENSE, remove outdated files
- Rewrite README.md with comprehensive documentation covering all
  features: compression engines, meta plugins, filter plugins, server
  mode, MCP integration, and configuration
- Add MIT LICENSE file
- Delete README.org (consolidated into README.md)
- Delete empty PLAN.md
- Update AGENTS.md with current build/test commands and conventions

Co-Authored-By: andrew/openrouter/hunter-alpha <noreply@opencode.ai>
2026-03-12 18:01:23 -03:00
79930f4b01 chore: remove outdated tool usage notes from AGENTS.md 2026-03-12 12:00:32 -03:00
9b7cbd5244 fix: resolve doctest failures, database bugs, and remove dead code
- Fix all 96 doctest failures across 20 files by adding hidden imports and
  proper test setup (68 pass, 33 intentionally ignored)
- Fix set_item_tags: wrap in transaction and replace item.id.unwrap() with
  proper error handling
- Fix get_items_matching: replace N+1 per-item meta queries with batch
  get_meta_for_items() call
- Fix get_item_matching: apply meta filtering instead of ignoring the parameter
- Remove duplicate doc comment in store_meta
- Remove dead code files: plugin.rs, plugins.rs, binary_detection.rs
  (never declared as modules)
- Apply cargo fmt formatting fixes
- Add keep.db to .gitignore
2026-03-12 11:58:44 -03:00
8a8a6e1c4b fix: correct critical bugs and improve pipe streaming performance
Critical bug fixes:
- save_item now returns real Item from database, not a hardcoded fake
- AsyncDataService::save() reuses self.sync_service instead of creating redundant instance
- GenerateStatus trait signature mismatch fixed (CLI/API decoupling)

Performance improvements (pipe path untouched):
- CompressionEngine::open() returns Box<dyn Read + Send> enabling true streaming
- mode_get eliminates triple full-file read (was sampling then re-reading entire file)
- FilteringReader adds fast-path bypass when no filters, pre-allocates temp buffer
- text.rs meta plugin processes &[u8] slice directly, eliminates data.to_vec() clone

API correctness:
- Tag parse errors now return 400 instead of being silently discarded
- compute_diff uses similar crate (LCS-based) instead of naive positional comparison

Cleanup:
- Modernize string formatting (format!({x})) across codebase
- Remove redundant DB query in get mode
- Derive Debug/ToSchema on public types
- Delete placeholder test files with no real assertions
- Extract parse_comma_tags utility function
2026-03-11 20:45:05 -03:00
e8ea42506e feat: unify CLI and API with DataService trait
- Add DataService trait with streaming support for save/get operations
- Implement SyncDataService for CLI and AsyncDataService for API
- Add missing API endpoints: DELETE /api/item/{id}, GET /api/item/{id}/info, GET /api/diff
- Add GET /api/plugins/status endpoint
- Preserve stdin/stdout streaming performance via Read trait
2026-03-10 22:31:31 -03:00
fb4c1a2b11 fix: add missing serde default to list_format field
Fixes deserialization failure in generate-config mode by adding
#[serde(default)] attribute to list_format field in Settings struct.
This allows the config library to provide sensible defaults when
no config file exists, resolving the error "missing field list_format".

Also unstages AGENT.md naming change since that's a different fix.
2026-03-09 20:13:55 -03:00
Andrew Phillips
fdeb5f7951 Ugh 2026-02-19 13:57:39 -04:00
Andrew Phillips
a72395fe83 refactor: simplify filter plugin interface to use &mut dyn Read/Write 2025-09-15 17:42:35 -03:00
Andrew Phillips
a8871a9575 docs: update build and formatting instructions in AGENT.md 2025-09-15 17:25:58 -03:00
Andrew Phillips
b538e2f8c1 feat: add magic file plugin with fallback to file command
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-12 12:29:33 -03:00
Andrew Phillips
82ec29f6a1 fix: resolve compilation errors by standardizing filter signatures and fixing ownership issues
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-12 12:28:51 -03:00
Andrew Phillips
02d9872b95 feat: implement MetaPluginExec for external command execution
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-12 12:21:30 -03:00
Andrew Phillips
cb1f330231 refactor: compose BaseMetaPlugin in remaining meta plugins
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-12 12:21:03 -03:00
Andrew Phillips
8693061338 refactor: use BaseMetaPlugin in ShellPidMetaPlugin
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-12 12:15:44 -03:00
Andrew Phillips
80e9457305 refactor: compose HostnameMetaPlugin with BaseMetaPlugin
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-12 12:15:26 -03:00
Andrew Phillips
9b85af439d refactor: update filter plugins to use boxed reader/writer parameters
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-12 11:56:11 -03:00
Andrew Phillips
0be54abe60 fix: resolve compilation errors and warnings
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-12 11:55:04 -03:00
Andrew Phillips
059bde09e4 refactor: simplify filter plugin signatures by removing boxed parameters 2025-09-12 10:36:09 -03:00
Andrew Phillips
9c354d5ef4 fix: complete magic file plugin implementation and error handling
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-12 10:27:16 -03:00
Andrew Phillips
84666155c4 feat: add fallback to file command when magic crate is disabled
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-12 10:26:06 -03:00
Andrew Phillips
27d3ecad04 refactor: simplify error handling and conditionals in meta plugins 2025-09-12 10:26:02 -03:00
Andrew Phillips
022bc70f53 docs: add AGENT.md and update compression engine module 2025-09-11 17:24:38 -03:00
Andrew Phillips
d27776ac23 chore: mark clippy fixes as completed in PLAN.md
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 12:34:49 -03:00
Andrew Phillips
fb70b7cc0b refactor: move doc comments above CompressionService struct
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 12:30:43 -03:00
Andrew Phillips
de1d546b67 chore: add clippy lint fixes plan to PLAN.md
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 12:14:10 -03:00
Andrew Phillips
91fc41f56e docs: remove outdated rustdoc plan 2025-09-11 12:14:05 -03:00
Andrew Phillips
5d7c0658b9 fix: fix typos and improve error handling in compression engines
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 12:00:45 -03:00
Andrew Phillips
f2951bf78e fix: resolve type mismatches and missing args module
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:58:47 -03:00
Andrew Phillips
4b67ff5763 fix: add missing clone_box impls for gzip and lz4 engines
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:58:19 -03:00
Andrew Phillips
dca1d6c6a4 fix: resolve compilation errors by adding Sync+Send bounds and fixing syntax
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:57:14 -03:00
Andrew Phillips
4dcbb7c942 feat: Add ProgramWriter utility struct for plugin communication
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:39:53 -03:00
Andrew Phillips
4c7b174dd5 feat: Add plugin system to allow external extensions 2025-09-11 11:39:52 -03:00
Andrew Phillips
b151998144 fix: Implement clone_box for CompressionEngineProgram
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:32:05 -03:00
Andrew Phillips
a23cf1bf3b fix: Close the unclosed delimiter in the compression engine trait
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:19:31 -03:00
Andrew Phillips
f7a73dd5e1 style: Remove trailing newline from trait definition 2025-09-11 11:19:30 -03:00
Andrew Phillips
0a267cf9ec docs: Update documentation for CompressionEngine trait methods
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:14:30 -03:00
Andrew Phillips
24e66ca75a refactor: Remove unused methods from CompressionEngine trait
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:10:51 -03:00
Andrew Phillips
0aaf22d4b6 docs: Fix missing docstring and implement cat and size methods
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:10:08 -03:00
Andrew Phillips
7be0b6735e fix: Remove stray doc comment lines that do not document anything
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:01:41 -03:00
Andrew Phillips
faeba25c53 feat: Add size method to CompressionEngine trait
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:00:43 -03:00
Andrew Phillips
b67018d981 refactor: Remove get_status_info from CompressionEngine trait
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 11:00:10 -03:00
Andrew Phillips
f2d340e778 refactor: Use is_internal instead of comparing against empty string for compression engine
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 10:58:19 -03:00
Andrew Phillips
08b5f284d3 feat: Add is_internal method to CompressionEngine trait
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 10:57:48 -03:00
Andrew Phillips
358df8acea fix: Restore methods for AutoFinishGzEncoder
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 10:55:13 -03:00
Andrew Phillips
f1f60f7178 refactor: Improve compression status and engine selection logic
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-11 10:54:40 -03:00
Andrew Phillips
a20f651c01 fix: Apply cfg_attr to fix conditional derive and schema attribute
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 18:16:48 -03:00
Andrew Phillips
07d0603d8e feat: Update Cargo.toml dependencies and features 2025-09-10 18:16:46 -03:00
Andrew Phillips
1098d58ff9 refactor: Add server configs and default meta plugins
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 18:11:48 -03:00
Andrew Phillips
5ee1a3cfca fix: Gates for server feature are placed correctly
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 18:11:00 -03:00
Andrew Phillips
6a4936d8d4 refactor: Conditionalize utoipa and flate2 based on features
Conditionalize `utoipa::ToSchema` derives and `#[schema]` attributes on the `server` feature, and `flate2` usage on the `gzip` feature, allowing compilation when these features are disabled.

Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 17:21:48 -03:00
Andrew Phillips
8848227837 fix: Correct mismatched types in compression info tuple
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 17:13:31 -03:00
Andrew Phillips
5cb2dd5c7e fix: Correctly display internal compression plugins in status output
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 17:12:11 -03:00
Andrew Phillips
9d3f9957ca fix: Correct arguments passed to mode_diff function call
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 17:08:08 -03:00
Andrew Phillips
08016d0305 fix: Resolve ambiguous associated type errors in filter_parser.rs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:59:11 -03:00
Andrew Phillips
4d16762d4c fix: Correct module path for FilterParser::Rule usage
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:55:19 -03:00
Andrew Phillips
94e82e132a docs: Update function documentation to use block comments
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:53:50 -03:00
Andrew Phillips
ec95bc8028 refactor: Convert unused doc comments to regular comments in status_plugins.rs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:53:05 -03:00
Andrew Phillips
0e82369d9a fix: Simplify Rule access in filter_parser module
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:51:13 -03:00
Andrew Phillips
7b81f04eea fix: Qualify ambiguous associated type Rule for FilterParser
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:50:47 -03:00
Andrew Phillips
4d566aff8b fix: Correct FilterParser rule referencing and remove unnecessary import
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:49:41 -03:00
Andrew Phillips
5f61f33e37 fix: Resolve ambiguous associated type in FilterParser by importing Rule
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:48:59 -03:00
Andrew Phillips
3a3466bf2e refactor: Remove unnecessary use FilterParser::Rule statement
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:39:14 -03:00
Andrew Phillips
1faaafbfda fix: Correctly import Pest Rule enum from FilterParser
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:36:37 -03:00
Andrew Phillips
eb935bae73 refactor: Improve filter parsing and error handling
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:23:38 -03:00
Andrew Phillips
0bf01e3105 fix: Correct grammar and parsing for filter strings
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:23:12 -03:00
Andrew Phillips
508b545861 refactor: Refactor filter parsing to use comma separation and JSON values
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:22:01 -03:00
Andrew Phillips
b0e359989a fix: Resolve build errors and warnings, and update grammar
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:20:08 -03:00
Andrew Phillips
71fa20ebb3 fix: Resolve filter parsing and default directory errors
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:15:51 -03:00
Andrew Phillips
9bade07938 fix: Resolve compilation errors for filter parser and config
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:14:50 -03:00
Andrew Phillips
0d68f39c08 fix: Correct Pest grammar and update ItemService initialization
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:14:00 -03:00
Andrew Phillips
84bf7ac5f4 refactor: Update filter parser tests to use get and as_str for options
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:12:33 -03:00
Andrew Phillips
22cd07284b fix: Resolve compilation errors for multiple definitions and parser issues
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:12:11 -03:00
Andrew Phillips
9e11756d4a docs: Fully document db module functions with examples and details
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 16:05:20 -03:00
Andrew Phillips
11ec6cf01e docs: Add Rustdoc for pages.rs structs and functions
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:56:45 -03:00
Andrew Phillips
e9ab630a74 docs: Add rustdoc for filter_plugin, binary_detection, and lib.rs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:48:54 -03:00
Andrew Phillips
b257a74162 fix: Remove extra closing brace from MetaPlugin impl
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:42:56 -03:00
Andrew Phillips
b8263ee29e docs: Add comprehensive Rustdoc to src/meta_plugin/read_rate.rs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:42:43 -03:00
Andrew Phillips
9f7534f0ae docs: Improve Rustdoc for compression engine, delete mode, and parser modules
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:40:04 -03:00
Andrew Phillips
6a79f0455c docs: Add Rustdoc to handle_status function in server status API
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:33:42 -03:00
Andrew Phillips
3df604e9bd docs: Mark documentation tasks as DONE in PLAN.md
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:32:19 -03:00
Andrew Phillips
2a4a7d46c4 docs: Add comprehensive Rustdoc for shell meta plugin
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:31:53 -03:00
Andrew Phillips
0e036e3789 docs: Enhance Rustdoc for CompressionService, StatusService, and MetaPluginExec
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:29:56 -03:00
Andrew Phillips
9f48d7980b docs: document src/services/types.rs, src/modes/common.rs, and src/services/error.rs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:27:45 -03:00
Andrew Phillips
3ddecc9ed5 docs: Add comprehensive Rustdoc for ExecFilter and its methods
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 15:12:49 -03:00
Andrew Phillips
f3a4894699 docs: Add Rustdoc comments for info and mcp modules and grep plugin
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:30:54 -03:00
Andrew Phillips
7ee8ef6ce6 docs: Add Rustdoc comments to mode_status_plugins function
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:29:49 -03:00
Andrew Phillips
88c7290a7b docs: Add comprehensive documentation for modes, services, and plugins
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:29:16 -03:00
Andrew Phillips
d44f3fbb5b docs: Add Rustdoc comments for various structs and functions
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:23:12 -03:00
Andrew Phillips
d219f557db docs: Add Rustdoc to list mode's show_list_structured function
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:15:59 -03:00
Andrew Phillips
f220ecd6ab style: Remove unnecessary triple backticks from module docstring
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:12:45 -03:00
Andrew Phillips
16c035eb50 docs: Add Rustdoc to gzip and user meta plugins, mark PLAN.md tasks done
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:12:25 -03:00
Andrew Phillips
ce9b823e17 docs: remove redundant rustdoc comments for structs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:05:53 -03:00
Andrew Phillips
6cff3bd165 docs: Add Rustdoc examples to server common types and compression engine
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:05:24 -03:00
Andrew Phillips
46a245a59a docs: Mark all completed items in PLAN.md for adding rustdoc
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:02:32 -03:00
Andrew Phillips
7e4b138b39 fix: Remove extraneous closing brace
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:01:20 -03:00
Andrew Phillips
4c47dceef5 docs: Add comprehensive rustdoc for meta_plugin/magic.rs, and update PLAN.md
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 14:00:43 -03:00
Andrew Phillips
9ad3c1d9c8 docs: Add rustdoc comments for text metadata plugin internals
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 13:57:51 -03:00
Andrew Phillips
099698e388 docs: Add Rustdoc to meta_plugin, filter_plugin, common, lib, and filter_service
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 13:52:46 -03:00
Andrew Phillips
70070de9fa docs: Add comprehensive rustdoc for pages and db modules, marking plan items done
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 13:42:32 -03:00
Andrew Phillips
557b821e14 docs: Replace PLAN.md content with Rustdoc generation plan
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 13:12:03 -03:00
Andrew Phillips
cb4581c4e3 docs: Add comprehensive rustdoc for filter_service.rs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 13:04:52 -03:00
Andrew Phillips
311aba39d4 docs: Add comprehensive rustdoc to item_service.rs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 13:03:11 -03:00
Andrew Phillips
606bb0f76c docs: Add comprehensive rustdoc to db.rs module, structs, and functions
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 13:00:10 -03:00
Andrew Phillips
538a67ee14 docs: Add comprehensive rustdoc to save mode functions and types
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:54:57 -03:00
Andrew Phillips
22b1b0657e docs: Add missing rustdoc for StatusService struct and methods
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:53:52 -03:00
Andrew Phillips
a7bcad40bb docs: Add comprehensive rustdoc to common module functions
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:53:21 -03:00
Andrew Phillips
c199590b3c docs: Add comprehensive rustdoc to item_service.rs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:47:20 -03:00
Andrew Phillips
c145974ce3 docs: Add Rustdoc comments to CompressionService methods
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:45:32 -03:00
Andrew Phillips
e4fc653397 docs: Add comprehensive Rustdoc comments to public APIs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:36:41 -03:00
Andrew Phillips
b48aade271 docs: Add Rustdoc plan to PLAN.md
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:32:08 -03:00
Andrew Phillips
653aebe1f0 docs: Remove outdated development plan document 2025-09-10 12:32:07 -03:00
Andrew Phillips
58b5c8187b docs: Add Rustdoc for modules, functions, and structs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:28:47 -03:00
Andrew Phillips
56a0ba2519 docs: Add rustdoc comments to services/types.rs structs and methods
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:26:23 -03:00
Andrew Phillips
6ccea1872c fix: Correctly handle string slices and no newline
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:17:01 -03:00
Andrew Phillips
978dae32d8 docs: Add Rustdoc to code and comments to grammar file
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:13:22 -03:00
Andrew Phillips
c965e9f51c docs: Add rustdoc to all files, document arguments and returns
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:10:36 -03:00
Andrew Phillips
c5f43b56f2 fix: Restore missing lines and refactor setup_diff_paths_and_compression
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:05:38 -03:00
Andrew Phillips
ddafeb3a28 docs: Add rustdoc for server, diff, and gzip components
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 12:04:46 -03:00
Andrew Phillips
a72352eb15 docs: Add rustdoc to db.rs functions for arguments and returns
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-10 11:57:06 -03:00
Andrew Phillips
93d99a644a docs: add rustdoc to compression_engine module
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-10 11:14:03 -03:00
Andrew Phillips
67a61093d5 docs: Add comprehensive rustdoc to src/db.rs structs and functions
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-10 11:03:17 -03:00
Andrew Phillips
cb716c161c docs: Document arguments and returns for utility and filter functions
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-10 10:59:29 -03:00
Andrew Phillips
d34472254b refactor: Update use statements to be relative
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:54:10 -03:00
Andrew Phillips
25b99b938e docs: Add rustdoc comments for functions, structs, and traits
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:52:31 -03:00
Andrew Phillips
ec4dfed2be feat: add skip_bytes, skip_lines, and strip_ansi filters
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:49:33 -03:00
Andrew Phillips
3f1c9265fe docs: Add rustdoc to filter plugin modules and arguments
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:48:50 -03:00
Andrew Phillips
edfe0fcd6e docs: Add comprehensive rustdoc to filter plugin modules
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:46:39 -03:00
Andrew Phillips
d894f686a1 fix: Clone the compression type before using it as an index
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:40:59 -03:00
Andrew Phillips
845ecf1498 fix: Prevent double move of compression_type in get_compression_engine
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:40:16 -03:00
Andrew Phillips
5521a352a7 fix: Add anyhow macro to imports in compression_engine.rs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:39:43 -03:00
Andrew Phillips
a528f47a14 fix: Declare filter_plugin module before its usage
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:39:06 -03:00
Andrew Phillips
fd95fbcac1 feat: Fallback to system commands for disabled compression features
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:37:49 -03:00
Andrew Phillips
6f1352238a feat: Make bzip2 compression optional
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:35:35 -03:00
Andrew Phillips
d9a36012bc feat: Make 'server' feature optional and add compile-time check
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:33:47 -03:00
Andrew Phillips
c5eb6d140a feat: Add magic feature to default build 2025-09-10 10:33:46 -03:00
Andrew Phillips
146bd2e569 feat: Make swagger an optional dependency, enabled by default
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:24:21 -03:00
Andrew Phillips
ea817ad629 refactor: Make router mutable in add_routes 2025-09-10 10:24:20 -03:00
Andrew Phillips
a010b232bf refactor: Remove deprecated server module file 2025-09-10 10:21:43 -03:00
Andrew Phillips
d5ff2e639d refactor: Deprecate src/modes/server.rs and move content to mod.rs
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:18:28 -03:00
Andrew Phillips
ad376c40f1 feat: add exec filter plugin for external command execution
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:13:17 -03:00
Andrew Phillips
f3cfb1faa6 refactor: Isolate conditional compilation of magic_file plugin import
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:12:02 -03:00
Andrew Phillips
a0e0126ff3 fix: Correct module paths and conditional compilation for magic feature
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:11:25 -03:00
Andrew Phillips
c24728202d feat: Add magic file meta plugin and fix build errors
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:09:23 -03:00
Andrew Phillips
5b41d2c95e test: Implement test_parse_multiple_filters test case
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:03:01 -03:00
Andrew Phillips
298773c507 fix: Correct optional dependency syntax in Cargo.toml features
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:01:41 -03:00
Andrew Phillips
39c3375cf5 refactor: Simplify server router merging with conditional compilation
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 10:00:30 -03:00
Andrew Phillips
8f3f6c05db feat: Make mcp support an optional feature, disabled by default
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 09:49:51 -03:00
Andrew Phillips
530615a6a1 docs: Initial development plan for the Keep project
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 09:42:19 -03:00
Andrew Phillips
9acce9f13d docs: Remove outdated code optimization plan document 2025-09-10 09:40:35 -03:00
Andrew Phillips
18e95f4085 feat: Add filter parser module
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 09:39:56 -03:00
Andrew Phillips
832330f31b feat: Add type and module reorganization for Services, Modes, Meta and Filter Plugins
Co-authored-by: aider (openai/andrew/openrouter/sonoma-sky-alpha) <aider@aider.chat>
2025-09-10 09:39:22 -03:00
Andrew Phillips
eaf47d7fed feat: Add TableStyle::Nothing to allow disabling table borders 2025-09-08 19:16:35 -03:00
Andrew Phillips
bb45af93fc fix: Ensure all match arms return the same type in table style setting
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 19:08:37 -03:00
Andrew Phillips
a8542d7dee fix: Resolve compilation errors and warnings
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 19:07:58 -03:00
Andrew Phillips
b88daca131 fix: Handle ColumnAlignment::Center in server pages
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 19:06:24 -03:00
Andrew Phillips
b9059da814 fix: Resolve multiple ColumnConfig definitions and type mismatches
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 19:04:30 -03:00
Andrew Phillips
0ab5c93845 feat: Add comprehensive table styling options
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 19:02:33 -03:00
Andrew Phillips
c9c3e2eb7e refactor: Do not specify default max_len for list columns
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:59:20 -03:00
Andrew Phillips
36a584113d feat: Add Center alignment to ColumnAlignment for comfy-table compatibility
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:57:59 -03:00
Andrew Phillips
935f829b42 refactor: Trim whitespace from end of each line in table output
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:53:54 -03:00
Andrew Phillips
9a6b0ceced fix: Use trim_fmt() before trim_end() for comfy-table output
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:52:48 -03:00
Andrew Phillips
8cb7559d5d fix: Trim trailing whitespace from printed tables
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:52:01 -03:00
Andrew Phillips
2dab5b9a51 refactor: Unstyle the info table
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:47:43 -03:00
Andrew Phillips
123e3b9846 fix: Remove non-existent max_height method call
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:45:55 -03:00
Andrew Phillips
94fc538a50 fix: Truncate cell content to max 3 lines with ellipsis
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:45:38 -03:00
Andrew Phillips
b41d710a35 fix: Use ASCII_FULL for tables when stdout is not a TTY
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:44:16 -03:00
Andrew Phillips
fc7ca8318b style: Use solid inner borders for tables 2025-09-08 18:44:15 -03:00
Andrew Phillips
ef43c8a8f8 feat: Limit list mode cells to a maximum height of 3
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:40:35 -03:00
Andrew Phillips
33a9049a1f refactor: Fix compilation by adding imports and adjusting table methods
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:31:35 -03:00
Andrew Phillips
8d56120b88 refactor: Remove unused size_column function
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:30:16 -03:00
Andrew Phillips
26a8712d82 refactor: Enhance table generation and clean up dependencies
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:29:50 -03:00
Andrew Phillips
b4046e0b18 refactor: Consolidate cell alignment logic in modes::list
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:27:45 -03:00
Andrew Phillips
eccdb0e13e refactor: Remove duplicated functionality by relying on comfy-table
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:26:39 -03:00
Andrew Phillips
50ee3ded9f style: Use simplified table styling for list output 2025-09-08 18:26:37 -03:00
Andrew Phillips
bf257b5ff3 refactor: Improve comfy_table usage and consistency
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:19:10 -03:00
Andrew Phillips
fb19499383 fix: Remove unused imports from various modules
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:12:54 -03:00
Andrew Phillips
1a0478a545 fix: Correct table cell alignment and string conversion
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:12:22 -03:00
Andrew Phillips
9f328a376f refactor: Migrate from prettytable to comfy-table for output formatting
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:09:47 -03:00
Andrew Phillips
d7f4724f26 refactor: Unify table styling with comfy-table in status and list modes
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:07:58 -03:00
Andrew Phillips
cba4566cdd fix: Remove unused prettytable::color import
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:06:45 -03:00
Andrew Phillips
c4a3c54ff3 refactor: Update comfy-table to 7.2.0 and adapt API changes
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 18:02:23 -03:00
Andrew Phillips
15417eb1d3 fix: Use comfy-table crate for table rendering
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 17:59:58 -03:00
Andrew Phillips
007f5e2377 refactor: Migrate table display from prettytable to comfytable
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 17:57:38 -03:00
Andrew Phillips
f9c4b709ad fix: Display tables with box characters in terminal 2025-09-08 17:57:37 -03:00
Andrew Phillips
e91431f6b6 feat: Add debugging for max_value_width calculation in info mode
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 17:53:19 -03:00
Andrew Phillips
08f37697d8 fix: Correctly calculate max value width for info table
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 17:51:07 -03:00
Andrew Phillips
12665032a3 fix: Resolve build errors by importing log::debug and removing unused imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 17:46:28 -03:00
Andrew Phillips
762fdfd876 fix: Truncate all info table value cells and add debug logging
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 17:45:22 -03:00
Andrew Phillips
d15ba05a44 fix: Correctly calculate info table column width for truncation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 17:38:56 -03:00
Andrew Phillips
aa8b942f2d feat: Improve info table rendering by truncating wide columns
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 17:35:14 -03:00
Andrew Phillips
44d039a7c2 refactor: Move terminal width detection to common utility function
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-08 17:25:36 -03:00
Andrew Phillips
8b38c3e345 fix: Resolve serde type mismatches and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 11:28:20 -03:00
Andrew Phillips
63b5b85476 fix: Correctly serialize filter plugin options for status display
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 11:25:06 -03:00
Andrew Phillips
5d301e5dbf fix: Remove unused build_compression_table function
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 11:21:04 -03:00
Andrew Phillips
b036674d46 feat: Import MetaPluginExec and derive Hash for MetaPluginType
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 11:08:01 -03:00
Andrew Phillips
fed3722ef9 fix: Resolve compilation errors by refactoring imports and type annotations
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:39:22 -03:00
Andrew Phillips
e1402807c4 feat: Register skip and tail filter plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:36:13 -03:00
Andrew Phillips
a76f3bfc56 refactor: Remove unused options method from SkipBytesFilter 2025-09-03 09:36:12 -03:00
Andrew Phillips
57413725c7 feat: Import all meta plugins to ensure registration
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:34:04 -03:00
Andrew Phillips
15496345d9 feat: Implement registry for meta plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:33:39 -03:00
Andrew Phillips
21f195d8f6 refactor: Use plugin registry for filter service discovery
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:31:05 -03:00
Andrew Phillips
96deafbf78 feat: Add pest and pest_derive dependencies 2025-09-03 09:31:04 -03:00
Andrew Phillips
383cf1e98f fix: Use function pointers for filter plugin registration
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:26:53 -03:00
Andrew Phillips
c952f62c21 feat: Register all available filter plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:25:43 -03:00
Andrew Phillips
63ce81c6ce fix: Add Clone derive and adjust sorting for FilterPluginInfo
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:19:20 -03:00
Andrew Phillips
5e866c7cbf fix: Resolve compilation errors with FilterOption and type mismatches
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:18:38 -03:00
Andrew Phillips
332a609d7f refactor: Display filter plugin details in status command
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:17:42 -03:00
Andrew Phillips
e5f71c7c5d feat: Enhance status with detailed filter plugin information
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:17:07 -03:00
Andrew Phillips
bfe56f5266 fix: Populate filter_plugins with available filter plugin names
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:14:34 -03:00
Andrew Phillips
3524a12ffd fix: Resolve compilation errors related to status and filter plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:13:26 -03:00
Andrew Phillips
09fa7576d0 fix: Add HashMap import to config module
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:12:48 -03:00
Andrew Phillips
4c9a8e8604 refactor: Centralize status info retrieval in status service
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:10:10 -03:00
Andrew Phillips
6aa26e7940 fix: Add missing imports for filter service functions
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:08:52 -03:00
Andrew Phillips
1de4863726 fix: Correct filter plugin calls and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:08:14 -03:00
Andrew Phillips
73415f89fc fix: Add missing filter_plugins field and implement get_available_filter_plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:07:14 -03:00
Andrew Phillips
a7b46658ac feat: display filter plugin information in status output
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:05:49 -03:00
Andrew Phillips
99656ea048 feat: Add filter plugin information to status service and display
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:05:11 -03:00
Andrew Phillips
1f983f2090 feat: Add placeholder for filter plugins table to status output
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:02:28 -03:00
Andrew Phillips
8f59cccbae refactor: Update status and status_plugins output for clarity and consistency
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 09:00:43 -03:00
Andrew Phillips
06d9d95972 fix: Correctly import and use meta_plugin module
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:56:23 -03:00
Andrew Phillips
3098010716 refactor: Import get_meta_plugin directly 2025-09-03 08:56:22 -03:00
Andrew Phillips
eef4996ed3 fix: Correctly import get_meta_plugin in status mode
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:55:56 -03:00
Andrew Phillips
7f10d615b2 fix: Move configured meta plugins to status command
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:55:12 -03:00
Andrew Phillips
de6c4d0c07 fix: Use FromStr trait for MetaPluginType parsing
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:53:06 -03:00
Andrew Phillips
738af256b0 fix: Correctly parse MetaPluginType from string and remove unused import
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:52:50 -03:00
Andrew Phillips
3a90c12dc2 fix: Correct MetaPluginType conversion and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:50:57 -03:00
Andrew Phillips
618c164d2d refactor: Remove plugin table building from status.rs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:50:00 -03:00
Andrew Phillips
20e406d5c8 refactor: Move status_plugins functionality to its own module
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:49:10 -03:00
Andrew Phillips
82575bd3a1 feat: Add status_plugins to manage plugin status 2025-09-03 08:49:09 -03:00
Andrew Phillips
cd16ff0352 fix: Improve --status-plugins output for various formats
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:46:57 -03:00
Andrew Phillips
e14c85a5af feat: Split out --status-plugins to show only plugin information
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:45:37 -03:00
Andrew Phillips
58bb70a2a4 fix: Implement missing options trait for filter plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:34:48 -03:00
Andrew Phillips
6d05859954 fix: Import FilterOption and implement options method for all filters
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:33:51 -03:00
Andrew Phillips
254ac6359b refactor: Unify filter plugin creation and option handling
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:26:44 -03:00
Andrew Phillips
bd2a8af186 feat: Implement structured filter options with FilterOption trait
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:24:55 -03:00
Andrew Phillips
1480ef504b feat: implement filter string parsing with Pest
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:14:36 -03:00
Andrew Phillips
f72a365c76 feat: add filter_parser module with pest grammar
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 08:13:40 -03:00
Andrew Phillips
71d7ec0851 feat: Implement clone_box for head, tail, and skip filters
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:51:01 -03:00
Andrew Phillips
19188fabb9 feat: Implement clone_box for StripAnsiFilter and GrepFilter
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:49:53 -03:00
Andrew Phillips
feb508bf27 feat: Implement Clone for Box<dyn FilterPlugin>
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:48:31 -03:00
Andrew Phillips
8c50af2246 fix: Implement Clone for FilterChain manually
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:48:00 -03:00
Andrew Phillips
2abdf5f2d9 feat: Make FilterChain clonable
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:47:14 -03:00
Andrew Phillips
0d575e8850 fix: Resolve type mismatches for Option<FilterChain> and Option<&FilterChain>
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:46:51 -03:00
Andrew Phillips
e34d0e315b refactor: Accept filter_chain by reference and clone when needed
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:46:24 -03:00
Andrew Phillips
5afe2f6bc8 feat: Add --filters option to --get and parse filters early
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:42:28 -03:00
Andrew Phillips
639f2d511d fix: Pass empty filters vector to mode_get to satisfy arity
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:35:24 -03:00
Andrew Phillips
2e62878acb fix: Resolve compilation errors with missing imports and closure moves
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:34:13 -03:00
Andrew Phillips
426eff309b refactor: Improve error handling and borrowing in item services
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:32:25 -03:00
Andrew Phillips
1bd3f95627 refactor: Introduce execute_blocking to reduce code duplication in AsyncItemService
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:29:30 -03:00
Andrew Phillips
493d28699c refactor: Consolidate binary detection, response building, and filter string logic
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:23:23 -03:00
Andrew Phillips
9b86baeb3e feat: Add binary detection and common server API utilities 2025-09-03 07:23:22 -03:00
Andrew Phillips
9717cd4b68 fix: Correct API call for item content stream with filter parameter
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-03 07:16:49 -03:00
Andrew Phillips
672404402c refactor: Remove individual content filtering options from API
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 19:03:19 -03:00
Andrew Phillips
a93b5e052a refactor: Reduce code duplication with helper functions
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 19:02:49 -03:00
Andrew Phillips
61d7dcb94e refactor: Consolidate item content filtering into a single filter string
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 18:54:50 -03:00
Andrew Phillips
d7a0ac96b3 fix: Adjust item content info streaming calls to match updated signature
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 18:38:26 -03:00
Andrew Phillips
8795756232 refactor: Simplify streaming content API to use a single filter option
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 18:37:15 -03:00
Andrew Phillips
2fcf922dd8 refactor: Simplify content streaming by consolidating filter parameters
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 18:36:54 -03:00
Andrew Phillips
22c91202a5 fix: Add context_bytes_len parameter to get_item_content_info_streaming
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 18:32:01 -03:00
Andrew Phillips
3ed54ce6b3 fix: Correct streaming content parameters and update deprecated macro
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 18:31:42 -03:00
Andrew Phillips
c55527ea6a feat: Implement new filter syntax with JSON options for all filter plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 18:24:09 -03:00
Andrew Phillips
9b7751fa50 refactor: Use strum for filter plugin type determination
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 17:26:55 -03:00
Andrew Phillips
21b8267dcb build: Add strip-ansi-escapes dependency 2025-09-02 17:26:54 -03:00
Andrew Phillips
44740518a7 feat: Add --filter option to --get mode for content filtering
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 17:21:52 -03:00
Andrew Phillips
95b703b301 fix: Remove mut from trait method parameters
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 17:02:57 -03:00
Andrew Phillips
5232e7d866 fix: Make reader and writer mutable in FilterPlugin and implementations
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 17:01:51 -03:00
Andrew Phillips
f527dfc83b fix: Adjust filter trait and implementations for mutable boxed I/O
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 16:33:53 -03:00
Andrew Phillips
288ab8a74d fix: Correct filter trait and chain implementation for mutability
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 16:32:18 -03:00
Andrew Phillips
765a46a8d2 fix: Adapt FilteringReader to use FilterChain for streaming
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 16:31:03 -03:00
Andrew Phillips
1cc1ccf15e refactor: Update FilterPlugin methods to use boxed Read/Write traits
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 16:26:13 -03:00
Andrew Phillips
4a1c3f6c92 fix: Make FilterPlugin trait object-safe and update implementations
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 16:24:04 -03:00
Andrew Phillips
e7cae3d12f refactor: Simplify filter plugin return values and apply io::copy directly
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 12:40:28 -03:00
Andrew Phillips
2773855cec refactor: Use io::copy to simplify StripAnsiFilter
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 11:58:22 -03:00
Andrew Phillips
dce89e2f67 perf: Use io::copy for SkipBytesFilter
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 11:57:54 -03:00
Andrew Phillips
44624ba3c1 refactor: Implement StripAnsiFilter in streaming fashion using strip-ansi-escapes::Writer
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 11:57:16 -03:00
Andrew Phillips
9a25bdea37 refactor: Optimize filter plugins to read in chunks of PIPESIZE
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 11:27:50 -03:00
Andrew Phillips
11559c7b26 perf: Optimize SkipBytesFilter by reading in chunks
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 11:22:25 -03:00
Andrew Phillips
21ba31e807 refactor: Update head, tail, skip filter plugins to use new API
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 11:15:05 -03:00
Andrew Phillips
099f3cde69 refactor: Update filter API to use Read/Write traits
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 11:12:50 -03:00
Andrew Phillips
fc413738b7 feat: Add strip_ansi filter plugin to remove ANSI escape sequences
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-09-02 10:57:01 -03:00
Andrew Phillips
ccabeabe1e feat: Add strip_ansi filter plugin 2025-09-02 10:57:00 -03:00
Andrew Phillips
7311988d4a feat: comment out all lines in generated config
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 15:39:06 -03:00
Andrew Phillips
173114a993 feat: add comments to generated config sections
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 15:37:00 -03:00
Andrew Phillips
49bd4a8885 feat: add env meta plugin to generate_config mode
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 15:00:33 -03:00
Andrew Phillips
8af59d0b3f fix: reduce hostname_short max length to 14
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:39:53 -03:00
Andrew Phillips
8900f9b93e feat: update default list format to match new configuration
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:39:12 -03:00
Andrew Phillips
7ae2a3919f feat: add env as default meta plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:38:04 -03:00
Andrew Phillips
c07c83fb8f fix: make cmd mutable and remove unused import
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:32:18 -03:00
Andrew Phillips
fbdcb94ba1 fix: remove unused imports and parameters
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:31:27 -03:00
Andrew Phillips
4472f3db94 feat: add argument validation for delete mode
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:28:57 -03:00
Andrew Phillips
6f05851282 fix: remove redundant validation in delete mode
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:28:36 -03:00
Andrew Phillips
99217c631b fix: allow empty ids and tags for --info and --get modes
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:26:39 -03:00
Andrew Phillips
bf5ea8dc08 feat: add tag support to --info mode
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:25:21 -03:00
Andrew Phillips
692a403a7e fix: make id mandatory for delete and optional for get/info
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:24:19 -03:00
Andrew Phillips
019591ae23 fix: enforce mandatory ID for --delete flag
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:20:39 -03:00
Andrew Phillips
8c40b4de28 feat: add required validation for ids_or_tags with info and delete flags
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:19:15 -03:00
Andrew Phillips
6b632ff244 fix: make ids_or_tags optional for non-required modes
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:18:50 -03:00
Andrew Phillips
d32a460e38 refactor: remove KEEP_META_* environment variable parsing
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:09:12 -03:00
Andrew Phillips
03916829b3 fix: remove unused imports and fix temporary value reference in options
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:05:11 -03:00
Andrew Phillips
ff97bce04b feat: add env meta plugin for environment variables
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 14:03:46 -03:00
Andrew Phillips
0dc6632e1c feat: add environment variable management for meta plugin 2025-08-29 14:03:42 -03:00
Andrew Phillips
ebd14db956 feat: add meta plugin exec implementation 2025-08-29 13:40:14 -03:00
Andrew Phillips
196fdbbda8 feat: add ringbuf and crossbeam-utils dependencies 2025-08-29 13:35:55 -03:00
Andrew Phillips
b58d0a2df5 feat: add "Keep - " prefix to page titles
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 13:19:39 -03:00
Andrew Phillips
4f417c29a8 feat: add item id to page title
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 13:18:12 -03:00
Andrew Phillips
9d9f98dc54 feat: add clickable tags to item details table
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 13:16:20 -03:00
Andrew Phillips
86dbf0c568 fix: convert metadata to use MetaData type
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:54:14 -03:00
Andrew Phillips
296807442e fix: add hostname metadata to response
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:51:28 -03:00
Andrew Phillips
e9f591d2c4 feat: handle numeric strings as IDs for get and info modes
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:45:01 -03:00
Andrew Phillips
219c368c05 feat: enforce numeric IDs for --info command
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:42:38 -03:00
Andrew Phillips
66f9b71258 feat: handle numeric tags as ids for info mode
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:40:15 -03:00
Andrew Phillips
32a79c0a1b feat: disable max length truncation for tags column in HTML
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:27:35 -03:00
Andrew Phillips
2b0e958b1f fix: add debug logging for tag counts per item
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:23:03 -03:00
Andrew Phillips
0c9e861307 feat: use existing items to get recent tags instead of database query
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:21:19 -03:00
Andrew Phillips
eb42b207f0 feat: add recent tags section to items page
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:20:34 -03:00
Andrew Phillips
467e91ab47 feat: make tags clickable for searching specific tags
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:19:37 -03:00
Andrew Phillips
e0536506b9 fix: remove unused http::StatusCode import
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:16:35 -03:00
Andrew Phillips
8005f9baf0 feat: add missing imports for Response and StatusCode
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:11:52 -03:00
Andrew Phillips
79b63d2d73 feat: add content-length header to responses
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:10:18 -03:00
Andrew Phillips
47fd796d50 feat: add unified styling with style.css route
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:06:32 -03:00
Andrew Phillips
e056678799 feat: make item ID clickable link to details page
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 12:04:16 -03:00
Andrew Phillips
809a1e6e2c feat: add explicit content-length header to non-streamed item list response
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:57:22 -03:00
Andrew Phillips
59e1ecd181 refactor: remove content_url from item metadata
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:52:25 -03:00
Andrew Phillips
713f33ad87 fix: remove Host extractor and update content_url format
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:48:55 -03:00
Andrew Phillips
aa00bb134b feat: add content_url to metadata responses
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:46:50 -03:00
Andrew Phillips
7b518eb2e9 fix: make verbose field public in OptionsArgs
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-29 11:41:31 -03:00
Andrew Phillips
93ebf3ccd0 fix: update module paths to use crate:: prefix
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-29 11:40:04 -03:00
Andrew Phillips
7132370dd2 fix: remove redundant module declarations from main.rs
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-29 11:39:15 -03:00
Andrew Phillips
9a32c68bd8 fix: update filter_plugin import paths to use keep namespace
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-29 11:35:25 -03:00
Andrew Phillips
21bd31b97e refactor: update imports to use relative paths for filter_plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:30:31 -03:00
Andrew Phillips
3e4d90afcf fix: replace external keep imports with internal crate imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:24:33 -03:00
Andrew Phillips
1d791b9430 fix: update filter_plugin import paths to use keep::filter_plugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-29 11:23:01 -03:00
Andrew Phillips
2e1ef484f3 fix: replace incorrect keep module imports with crate
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-29 11:22:37 -03:00
Andrew Phillips
b1f2fac564 fix: update filter_plugin import paths
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-29 11:22:13 -03:00
Andrew Phillips
56adbbf345 fix: replace incorrect module imports with crate paths
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-29 11:21:39 -03:00
Andrew Phillips
76ad07e2ee fix: update imports to use keep crate instead of binary crate
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:20:44 -03:00
Andrew Phillips
dac0148176 fix: replace incorrect crate imports with proper module paths
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:18:57 -03:00
Andrew Phillips
6143055aa6 fix: update filter_plugin imports to use keep crate
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:18:22 -03:00
Andrew Phillips
ec64c79bc2 fix: update import paths to use crate scope
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:16:50 -03:00
Andrew Phillips
93e4a6154c fix: update filter_plugin import paths to use keep:: prefix
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:15:49 -03:00
Andrew Phillips
d36db194f8 refactor: update import paths to use crate-relative paths
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 11:15:02 -03:00
Andrew Phillips
091b5e5e70 fix: update filter_plugin import paths to use keep crate
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:31:50 -03:00
Andrew Phillips
7c77e14c70 fix: update service imports to use crate paths
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:31:11 -03:00
Andrew Phillips
359f9f2c60 fix: update filter_plugin import paths to use keep namespace
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:29:37 -03:00
Andrew Phillips
886ab27dc6 fix: correct filter_plugin import paths
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:28:07 -03:00
Andrew Phillips
8cdcf987d2 fix: correct filter_plugin import paths
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:26:51 -03:00
Andrew Phillips
f098dece8d fix: correct filter_plugin import paths
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:26:19 -03:00
Andrew Phillips
8e58367675 fix: update filter_plugin import paths
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:25:43 -03:00
Andrew Phillips
6b7598e496 fix: update filter_plugin import paths
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:25:06 -03:00
Andrew Phillips
f947b06725 fix: correct filter_plugin import paths
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:22:57 -03:00
Andrew Phillips
470bb98dbb fix: update incorrect import paths for filter_plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:21:42 -03:00
Andrew Phillips
694ad0e63a fix: update filter_plugin import paths and mark unused field
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:21:12 -03:00
Andrew Phillips
625236f8df fix: remove unused parameter prefixes and update tail filter implementation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:20:15 -03:00
Andrew Phillips
1a4e9d531a fix: make finish method public and fix ringbuf method names
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:18:57 -03:00
Andrew Phillips
3cfcbf0edd fix: remove unused imports and fix tail filter errors
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:18:18 -03:00
Andrew Phillips
b7237595df fix: import missing FilterPlugin trait
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:16:14 -03:00
Andrew Phillips
bc085ae0fb feat: add filter_plugin module to crate root
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-29 10:15:48 -03:00
Andrew Phillips
2e40ab7a45 feat: add convenience methods to filter service and improve filter chain processing
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 22:16:59 -03:00
Andrew Phillips
01b7046970 feat: add filtering reader implementation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 22:00:33 -03:00
Andrew Phillips
c3e3ab1e46 feat: implement filter service integration
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 21:59:49 -03:00
Andrew Phillips
e664429465 fix: mark unused parameters with underscores
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 21:41:43 -03:00
Andrew Phillips
123433e39b fix: add missing arguments to get_item_content_info_streaming call
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 21:17:57 -03:00
Andrew Phillips
35bbf42e24 feat: update item streaming and binary check parameters
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 21:10:34 -03:00
Andrew Phillips
fe19ba0c5c fix: remove filter_service and fix function arguments
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 21:08:41 -03:00
Andrew Phillips
8fcccf68e3 fix: remove unused filter_plugin import and unused ringbuf import
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 21:05:23 -03:00
Andrew Phillips
4c8466bb21 refactor: reduce code duplication in filter and item services
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-28 20:51:39 -03:00
Andrew Phillips
5542f5592a refactor: remove unused line range options from text meta plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:39:40 -03:00
Andrew Phillips
35e2368dea refactor: move filtering logic to filter plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:38:57 -03:00
Andrew Phillips
ee2a9c63ee refactor: remove LineRangeFilter implementation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:36:41 -03:00
Andrew Phillips
272f75349c refactor: remove filter implementations from item_service
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:36:18 -03:00
Andrew Phillips
66696af67e refactor: remove old filter implementations and use filter plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:34:38 -03:00
Andrew Phillips
4cae92f7cd feat: add filter plugin system with chained filters
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:30:37 -03:00
Andrew Phillips
5cfdc7e35a feat: add filter plugin modules and services 2025-08-28 20:30:29 -03:00
Andrew Phillips
3b79ceca0e chore: remove unused binary meta plugin 2025-08-28 20:19:46 -03:00
Andrew Phillips
ee555b253f feat: add grep option to content endpoints
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:16:24 -03:00
Andrew Phillips
a419ae960c feat: add content filtering options to content endpoints
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:13:06 -03:00
Andrew Phillips
9ef4ba2abe refactor: optimize tail filter to use ring buffer directly
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:09:45 -03:00
Andrew Phillips
6c29c9c4c5 feat: add head/tail/line range processing for text meta plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:06:30 -03:00
Andrew Phillips
dc2bd8dcdf feat: implement tail filter using ringbuf crate
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 20:03:50 -03:00
Andrew Phillips
e99d7ad0ea feat: implement streaming support for tail and line range filters
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 18:24:47 -03:00
Andrew Phillips
c50a7db130 feat: implement content filtering for non-streaming and improve streaming tail handling
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 18:22:28 -03:00
Andrew Phillips
1ded347355 feat: add head/tail/line range options to content endpoints
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 18:20:54 -03:00
Andrew Phillips
7d4eda55e0 docs: add as_meta parameter for AI agents in content endpoints
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 18:02:07 -03:00
Andrew Phillips
729d7a060b feat: add as_meta parameter to content endpoint
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:57:50 -03:00
Andrew Phillips
2ab0ed1938 fix: add missing debug macro import from log crate
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:43:19 -03:00
Andrew Phillips
b72431922b feat: add debug logging for item content streaming
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:42:05 -03:00
Andrew Phillips
2823fa0466 feat: add stream parameter to item content response
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:37:10 -03:00
Andrew Phillips
d730e8d235 feat: add stream parameter support for item content responses
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:36:18 -03:00
Andrew Phillips
78cc39fbd2 fix: clone accept header before moving request
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:33:16 -03:00
Andrew Phillips
2e20f74675 feat: add logging for accept header in http requests
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:31:24 -03:00
Andrew Phillips
34642abaf9 feat: implement ToSchema for ItemQuery and ItemContentQuery
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:29:35 -03:00
Andrew Phillips
efabb9c3ca feat: add default_stream function with false return value
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:27:10 -03:00
Andrew Phillips
79179cc465 feat: add stream option to content API routes
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:26:22 -03:00
Andrew Phillips
3d4ed341e7 fix: remove automatic addition of digest plugin and use config for enabled_meta_plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:11:01 -03:00
Andrew Phillips
d693f9d5f2 feat: add status endpoint to server api 2025-08-28 17:10:58 -03:00
Andrew Phillips
fe41f95570 refactor: update meta plugins structure to use map and vector
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:07:05 -03:00
Andrew Phillips
983af9b30f feat: add meta plugin options to status information
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 17:04:14 -03:00
Andrew Phillips
2523990075 fix: add missing imports and update status response type
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 16:59:05 -03:00
Andrew Phillips
0fc34a9fcb feat: add status service module to services
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 16:58:05 -03:00
Andrew Phillips
d5c36155e3 feat: add unified status service implementation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 16:57:04 -03:00
Andrew Phillips
c10782c549 feat: add status service 2025-08-28 16:57:01 -03:00
Andrew Phillips
91bd270d9e refactor: prefix operation ids with "keep_"
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 16:51:11 -03:00
Andrew Phillips
51db54e0aa docs: remove mcp sse endpoint from openapi documentation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 16:47:43 -03:00
Andrew Phillips
10c7ab9679 docs: simplify API endpoint descriptions
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 16:45:43 -03:00
Andrew Phillips
fee576f638 feat: restructure routes to separate authenticated and public endpoints
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 16:21:58 -03:00
Andrew Phillips
a1494717b9 fix: exclude openapi spec from authentication middleware
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 16:21:34 -03:00
Andrew Phillips
b8f42ed03f feat: add WWW-Authenticate header for 401 responses
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 16:18:28 -03:00
Andrew Phillips
9b793fa7ba fix: mark cmd as unused to remove warning
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 15:20:55 -03:00
Andrew Phillips
7be728e233 fix: update AsyncItemService::new() to accept wrapped Command and Settings types
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 15:19:01 -03:00
Andrew Phillips
06a4b3ec73 fix: update meta map iteration to use tuple pattern
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 15:17:45 -03:00
Andrew Phillips
9ab4341c2e fix: fix meta processing in pages.rs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 15:17:31 -03:00
Andrew Phillips
761542743c feat: implement configurable columns for item list page
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 15:15:38 -03:00
Andrew Phillips
888e6457dd fix: update AsyncItemService constructor and remove unused import
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-28 15:04:17 -03:00
Andrew Phillips
dd88547c37 fix: remove unused MetaPlugin::binary variant
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-28 15:01:24 -03:00
Andrew Phillips
56a7487abd fix: update server function signatures
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-28 14:59:45 -03:00
Andrew Phillips
b10781a8c0 fix: update async item service constructor parameters
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-28 14:59:32 -03:00
Andrew Phillips
50806ba81a fix: add missing cmd and settings fields to AppState
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-28 14:56:49 -03:00
Andrew Phillips
ec6f0de95b fix: update async item service initialization
feat: remove unused meta plugin imports
fix: update meta plugin type implementation

Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 14:44:22 -03:00
Andrew Phillips
cb8caed662 fix: remove unused meta plugin imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 14:30:25 -03:00
Andrew Phillips
bfa233330e feat: use configured meta plugins for MCP items
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 14:24:05 -03:00
Andrew Phillips
d5490eacb9 fix: remove binary plugin type references
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 14:14:13 -03:00
Andrew Phillips
f7854a76ed refactor: remove binary plugin and its references
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 14:12:19 -03:00
Andrew Phillips
c7d58636b0 feat: add binary meta plugin implementation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 14:07:35 -03:00
Andrew Phillips
52dc8cea32 feat: update dependencies and remove binary and command meta plugins 2025-08-28 13:11:19 -03:00
Andrew Phillips
40e4fcc74a refactor: remove binary plugin and its references
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 13:10:52 -03:00
Andrew Phillips
f6220eb16e feat: replace binary detection with text metadata check
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-28 13:09:00 -03:00
Andrew Phillips
9e3df98e79 feat: make hostname option boolean-only and simplify hostname output logic
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 23:02:48 -03:00
Andrew Phillips
21edfbd633 fix: replace null outputs with their keys
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:57:29 -03:00
Andrew Phillips
3cc0fd3b22 fix: handle null values to disable outputs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:53:51 -03:00
Andrew Phillips
940dc2efd7 fix: handle null values in YAML output conversion
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:53:01 -03:00
Andrew Phillips
a53591d28e fix: remove unnecessary plugin initialization calls
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:50:09 -03:00
Andrew Phillips
6a2a5ad67f feat: use actual default options and outputs from meta plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:48:57 -03:00
Andrew Phillips
2a92ecaf59 fix: import MetaPlugin trait and use empty hashmaps for meta plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:46:03 -03:00
Andrew Phillips
b59875d1df feat: use default options and outputs for meta plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:44:38 -03:00
Andrew Phillips
f2e75d16fc feat: update default config to match provided structure
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:35:34 -03:00
Andrew Phillips
5541220a68 feat: update config generation to match settings struct format
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:31:52 -03:00
Andrew Phillips
bc78075b1a feat: add --generate-config mode to output default config
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:05:09 -03:00
Andrew Phillips
fb636d3077 feat: add generate_config module 2025-08-27 22:04:09 -03:00
Andrew Phillips
15baa8f297 refactor: rename command plugin to exec
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 22:01:47 -03:00
Andrew Phillips
e374e2d99b fix: implement debug for MetaPluginCommand
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:56:03 -03:00
Andrew Phillips
db5b3153c0 fix: implement debug and default for meta plugin components
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:55:29 -03:00
Andrew Phillips
a909fdb2bd fix: enable full features for derive_more
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:53:49 -03:00
Andrew Phillips
45a528118c fix: remove duplicate thiserror dependency
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:52:48 -03:00
Andrew Phillips
2e4cacaaba feat: add derive_more for NumberOrString and ProgramWriter
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:50:04 -03:00
Andrew Phillips
2435c8bebf docs: update code optimization plan with completion status
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:49:01 -03:00
Andrew Phillips
bacfaa4fc3 refactor: remove manual Debug implementation for MetaPluginCommand
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:47:40 -03:00
Andrew Phillips
1025f1bc01 feat: add thiserror and derive_more for error handling and boilerplate reduction
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:45:49 -03:00
Andrew Phillips
c056e8e2f2 build: update derive_more to version 2.0 2025-08-27 21:45:46 -03:00
Andrew Phillips
80deefb8b7 chore: add code optimization plan
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:43:31 -03:00
Andrew Phillips
2fe9d593b1 refactor: remove manual Default implementation from DigestMetaPlugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:38:40 -03:00
Andrew Phillips
01ba00db4b feat: add smart default for hostname meta plugin and debug for api response
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:37:32 -03:00
Andrew Phillips
f0a2cf32ac chore: add derive_more and smart-default crates
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:36:44 -03:00
Andrew Phillips
bd32c68056 fix: remove meta_name from DigestMetaPlugin default implementation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:27:30 -03:00
Andrew Phillips
ea475386d6 fix: remove unused meta_name fields from meta plugin structs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:26:00 -03:00
Andrew Phillips
da9ebb25da fix: add missing MetaPluginType import
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:23:49 -03:00
Andrew Phillips
a820078214 fix: add missing MetaPluginType imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:19:59 -03:00
Andrew Phillips
fc54b8ff8f fix: remove duplicate meta_type and replace meta_name with meta_type
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:17:48 -03:00
Andrew Phillips
e279af07d3 fix: replace meta_name with meta_type in default_outputs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:12:26 -03:00
Andrew Phillips
7b1820cb63 refactor: remove redundant meta_name field and simplify default outputs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:12:06 -03:00
Andrew Phillips
b8d51e2fa2 fix: add missing closing brace for is_supported method
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:05:24 -03:00
Andrew Phillips
79fdf05d84 refactor: replace meta_name with MetaPluginType from strum
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 21:04:52 -03:00
Andrew Phillips
892a3f24a5 feat: use process_metadata_outputs for output mapping in command plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 20:59:02 -03:00
Andrew Phillips
b573c0dbe7 fix: prevent emitting disabled metadata outputs as null
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 20:50:02 -03:00
Andrew Phillips
9d8506461c feat: add clone implementation to MetaPluginInfo
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-27 20:08:53 -03:00
Andrew Phillips
10cd22ee1a refactor: rename meta to plugin in table header 2025-08-27 20:07:29 -03:00
Andrew Phillips
7bc9dedccd feat: sort meta plugins by name in status tables
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-27 20:06:56 -03:00
Andrew Phillips
71b097c112 refactor: remove unused loop index variable
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-27 20:06:08 -03:00
Andrew Phillips
bc90085f05 feat: update table formatting and meta plugins display order 2025-08-27 20:06:07 -03:00
Andrew Phillips
564d593f04 feat: add separator lines between meta plugins in status table
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-27 19:55:46 -03:00
Andrew Phillips
46cf015572 fix: convert Vec<&String> to Vec<String> for join method compatibility
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 19:49:29 -03:00
Andrew Phillips
091f750bd8 feat: update meta plugin table to show options and sorted output keys
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 19:48:03 -03:00
Andrew Phillips
8cd1d6ddf2 refactor: rename program plugin to command
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 19:44:03 -03:00
Andrew Phillips
1d61ac1243 feat: use strum to serialize MetaPluginType
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 19:40:29 -03:00
Andrew Phillips
95c79cacf6 feat: add serialize and deserialize to MetaPluginType
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 19:39:10 -03:00
Andrew Phillips
0d6d200ab2 fix: remove unused MetaPluginType variants
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 19:38:20 -03:00
Andrew Phillips
4f2e76e833 feat: add program plugin for running ad-hoc commands
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 19:35:36 -03:00
Andrew Phillips
96cb67259d build: remove to_snake_case_string and to_snake_case_trait dependencies 2025-08-27 18:35:18 -03:00
Andrew Phillips
3e7f491b74 fix: handle disabled text outputs properly
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 18:32:37 -03:00
Andrew Phillips
695c51a97e feat: sort and format meta plugins table outputs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 18:14:54 -03:00
Andrew Phillips
42a10cc2d5 fix: ensure outputs are properly disabled in constructors
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 18:11:32 -03:00
Andrew Phillips
324b96d1e1 refactor: remove initialize() call and use plugin outputs directly
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 18:09:17 -03:00
Andrew Phillips
f056b16c65 fix: filter out disabled outputs in status display
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 18:07:38 -03:00
Andrew Phillips
75222eeb7f fix: handle hostname false option properly
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 18:05:16 -03:00
Andrew Phillips
15e7c2b6e5 feat: handle disabled hostname outputs in plugin and simplify status display
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 18:01:40 -03:00
Andrew Phillips
aa2534c901 fix: handle disabled digest outputs properly
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 18:00:24 -03:00
Andrew Phillips
285d04aa9a fix: handle hostname_short disabled state properly
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:55:27 -03:00
Andrew Phillips
a708186b4f fix: wrap string values in Value::String for process_metadata_outputs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:50:03 -03:00
Andrew Phillips
97fb35b5f0 fix: update process_metadata_outputs to handle serde_yaml::Value
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:47:48 -03:00
Andrew Phillips
cb685d4329 feat: set unused text outputs to null based on method options
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:45:33 -03:00
Andrew Phillips
d013b60fc1 feat: set unused digest outputs to null based on method
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:42:55 -03:00
Andrew Phillips
430eafcf80 feat: add configurable hostname options and outputs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:39:46 -03:00
Andrew Phillips
2d1174266d feat: add default boolean options for text plugin statistics
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:31:13 -03:00
Andrew Phillips
d6906e5ed9 feat: add default options to status table display
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:27:37 -03:00
Andrew Phillips
19848fd379 feat: add default options to meta plugins status table
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:27:11 -03:00
Andrew Phillips
b802c4f6f0 fix: resolve moved value errors and unused variable warnings
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:08:57 -03:00
Andrew Phillips
982c73dc03 fix: remove redundant default options merge and use effective_options directly
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 17:00:04 -03:00
Andrew Phillips
3e8d4b223b refactor: replace options_to_serialize with effective_options
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 16:59:50 -03:00
Andrew Phillips
1813e21e75 feat: display effective plugin options including defaults
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 16:59:15 -03:00
Andrew Phillips
03b3eea957 feat: extract meta plugins configured into separate table
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 16:36:45 -03:00
Andrew Phillips
90323ab6b1 refactor: remove enabled column and rename meta plugins table
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 16:34:59 -03:00
Andrew Phillips
c381e3aa38 fix: handle serde_yaml Value::Tagged variant
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 16:29:08 -03:00
Andrew Phillips
7f9b5e3423 fix: handle serde_yaml::Value to_string conversion properly
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 16:04:39 -03:00
Andrew Phillips
45d3ee3bfb feat: simplify meta plugin outputs display to show key or key->value
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 16:03:44 -03:00
Andrew Phillips
14c5f926a4 feat: display default options for digest and magic_file plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 16:00:06 -03:00
Andrew Phillips
74ebb87823 fix: display default digest plugin options
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 15:57:25 -03:00
Andrew Phillips
e0e5f5ff34 fix: clone meta_plugin_type to avoid move error
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 15:55:21 -03:00
Andrew Phillips
b23ec5604b fix: convert outputs to serde_yaml::Value type
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 15:54:17 -03:00
Andrew Phillips
a402ef413d feat: display merged plugin options and outputs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 15:53:03 -03:00
Andrew Phillips
31ed7b1b68 fix: properly merge default and user-provided plugin options
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 15:52:31 -03:00
Andrew Phillips
5a0c8777b2 fix: always display options and outputs sections for meta plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 15:49:28 -03:00
Andrew Phillips
55ac1758f3 feat: add meta plugins information to config table
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 15:45:38 -03:00
Andrew Phillips
8c8ac4f5be feat: display merged meta plugin options in status config
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 15:45:16 -03:00
Andrew Phillips
648c4e0cba fix: borrow compression string for Cell::new
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 15:31:15 -03:00
Andrew Phillips
7d42c5735e feat: add config section to status output
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 14:51:12 -03:00
Andrew Phillips
1159bbe0f5 fix: update meta plugin function calls and imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 14:02:59 -03:00
Andrew Phillips
442f20dda3 refactor: merge get_meta_plugin_with_config into get_meta_plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 14:01:18 -03:00
Andrew Phillips
fdcccc844e refactor: remove new_simple methods and replace with new(None, None)
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 13:59:37 -03:00
Andrew Phillips
de6cdf6bfc refactor: remove deprecated file magic plugin types 2025-08-27 13:59:31 -03:00
Andrew Phillips
c91f135458 fix: convert Vec<&String> to Vec<&str> for join method compatibility
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 13:50:36 -03:00
Andrew Phillips
97c331c2b2 feat: display only meta plugin output keys
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 13:49:44 -03:00
Andrew Phillips
57088471a1 chore: remove debug logging from text plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 13:44:52 -03:00
Andrew Phillips
d527caa7bd fix: change default value for track_line_median_len to false 2025-08-27 13:44:50 -03:00
Andrew Phillips
a3ff74c8e9 refactor: extract helper methods to reduce code duplication in TextPlugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 12:21:52 -03:00
Andrew Phillips
89d62c7b11 fix: remove deprecated strum::ToString and fix HashMap type conversion
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 12:17:09 -03:00
Andrew Phillips
091634433b feat: add to_snake_case_string and to_snake_case_trait dependencies 2025-08-27 12:15:48 -03:00
Andrew Phillips
90fd8d013d refactor: replace custom snake case macro with strum implementation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 12:15:18 -03:00
Andrew Phillips
8857fc86cd refactor: replace custom proc-macro with strum for snake_case serialization
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 12:13:04 -03:00
Andrew Phillips
697ec44f4d fix: resolve proc-macro reserved keyword and trait export issues
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 12:09:21 -03:00
Andrew Phillips
6579d47821 feat: add to_snake_case_string proc macro implementation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 12:07:16 -03:00
Andrew Phillips
2f0e7e1c5e fix: add to_snake_case_string dependency and fix imports and type mismatch
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 11:56:41 -03:00
Andrew Phillips
d442f41477 feat: add ToSnakeCaseString derive macro support
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:58:15 -03:00
Andrew Phillips
539f99f803 fix: replace to_string with explicit plugin name mapping
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:50:38 -03:00
Andrew Phillips
0ed7e3aae7 refactor: update plugin creation to use consistent config approach
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:50:11 -03:00
Andrew Phillips
7bc6dd89a1 fix: update meta service to use new plugin configuration approach
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:48:48 -03:00
Andrew Phillips
5c2b56c06a feat: add meta plugin configuration support
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:46:11 -03:00
Andrew Phillips
d5566e66c5 refactor: remove default_options method from MetaPlugin trait
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:44:44 -03:00
Andrew Phillips
80f8cf7eb7 refactor: remove redundant configure_outputs method
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:44:28 -03:00
Andrew Phillips
b7bf9b20de refactor: remove redundant configure_options and default_options methods
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:43:30 -03:00
Andrew Phillips
5d518711d5 feat: add md5 support and remove deprecated meta plugin 2025-08-27 10:37:07 -03:00
Andrew Phillips
e8f2c00416 feat: add line length statistics tracking flags
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:35:14 -03:00
Andrew Phillips
1ea4fc2180 feat: round text_line_mean_len to nearest integer
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:18:04 -03:00
Andrew Phillips
9088b76067 fix: add debug logging and set default values for text plugin options
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:09:43 -03:00
Andrew Phillips
e10605bb7e feat: add debug logging for line length statistics tracking
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 10:05:21 -03:00
Andrew Phillips
9a052bddd7 feat: add text line length statistics and options
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-27 09:24:31 -03:00
Andrew Phillips
6e8ff406c8 fix: remove unused digest field from Settings struct
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 23:52:07 -03:00
Andrew Phillips
560acc0235 fix: add missing std::io::Write import for md5::Context write method
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 23:48:58 -03:00
Andrew Phillips
af4f88a0fc fix: implement debug for hasher and fix md5 update method
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 23:48:16 -03:00
Andrew Phillips
8f5ec6381f fix: update md5 usage and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 23:47:18 -03:00
Andrew Phillips
34c942e73b refactor: unify digest plugin type handling
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 23:44:56 -03:00
Andrew Phillips
53df5d9260 feat: implement single hasher selection for digest plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 23:40:49 -03:00
Andrew Phillips
538d565341 feat: unify digest plugin types and always compute all hashes
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 23:38:35 -03:00
Andrew Phillips
cb83cc4b77 feat: add support for multiple hash methods in digest plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 23:35:40 -03:00
Andrew Phillips
25acb056d7 refactor: remove transaction usage from save_item method
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 23:24:05 -03:00
Andrew Phillips
efc71d6f0e feat: add debug logs for meta service plugin initialization
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 22:43:44 -03:00
Andrew Phillips
df18390a54 refactor: remove database transactions from item service
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 21:56:06 -03:00
Andrew Phillips
379c45b556 fix: resolve iterator first() error and remove unused import
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 21:47:23 -03:00
Andrew Phillips
fbffd010be fix: prioritize DNS resolution over hostname -f for hostname lookup
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 21:40:58 -03:00
Andrew Phillips
23906d4796 feat: improve hostname resolution with FQDN and IPv6 support
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 21:26:29 -03:00
Andrew Phillips
81ac8fcfbb feat: implement hostname resolution using gethostname and dns-lookup
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 21:16:51 -03:00
Andrew Phillips
2a94f5f155 feat: switch to hostname crate for full hostname resolution
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 21:15:19 -03:00
Andrew Phillips
0b751ca34b feat: add debug logging for hostname plugin options and values
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 21:11:37 -03:00
Andrew Phillips
dd217d6c6a fix: prevent text plugin from finalizing in update method
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 20:57:17 -03:00
Andrew Phillips
a92076bbec fix: prevent premature finalization when text buffer reaches max size
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 20:55:26 -03:00
Andrew Phillips
f2161c32f8 feat: add debug logs for text plugin initialization and finalization
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 20:54:38 -03:00
Andrew Phillips
6a1ac7284e feat: add debug logs for text metadata processing
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 20:52:52 -03:00
Andrew Phillips
295b565cd6 feat: re-enable shell-related plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 20:46:42 -03:00
Andrew Phillips
ee856b238d fix: comment out shell-related meta plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 20:44:26 -03:00
Andrew Phillips
8c1fad68a5 feat: add cwd and user meta plugins and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 20:44:01 -03:00
Andrew Phillips
a583a8449c refactor: update module imports and remove unused shell plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 20:43:34 -03:00
Andrew Phillips
aa6a4f1015 refactor: resolve meta plugin module ambiguity
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 20:38:59 -03:00
Andrew Phillips
ef1cb4b5fb refactor: resolve module conflict and reorganize meta plugin structure
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 20:32:55 -03:00
Andrew Phillips
73f23ff036 refactor: remove system directory from meta_plugin 2025-08-26 20:29:40 -03:00
Andrew Phillips
29079ccb24 refactor: update imports after moving system module files
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 20:25:59 -03:00
Andrew Phillips
0b0a601483 refactor: extract read_time and read_rate plugins from digest.rs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 20:20:40 -03:00
Andrew Phillips
f23cc6e94f feat: add termsize and tokio-util dependencies and remove system.rs 2025-08-26 20:16:08 -03:00
Andrew Phillips
7b945b4f4f refactor: split system plugins into individual files
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 20:15:23 -03:00
Andrew Phillips
6becdb4fbd fix: resolve mutable borrow conflicts in text meta plugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:49:57 -03:00
Andrew Phillips
adc16bd761 refactor: move helper methods out of trait implementation
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:49:09 -03:00
Andrew Phillips
549a671cf9 feat: add binary detection and word/line count helper methods
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:47:01 -03:00
Andrew Phillips
43088ad6a0 fix: move helper methods to impl block
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:46:46 -03:00
Andrew Phillips
e9b9532160 refactor: extract binary detection and word line count logic into helper methods
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:44:01 -03:00
Andrew Phillips
0ad8f3ccfa refactor: change buffer to Option<Vec<u8>> and drop it after binary detection
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:40:23 -03:00
Andrew Phillips
e2bef42a55 feat: fix text plugin word and line count tracking
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:37:21 -03:00
Andrew Phillips
a620db8cfe feat: add word and line count output in finalize method
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:22:42 -03:00
Andrew Phillips
cf4254750d feat: move word and line count output to finalize method
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:20:14 -03:00
Andrew Phillips
0b57da071a feat: implement accurate word counting across block boundaries
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:12:27 -03:00
Andrew Phillips
9f9e2749a9 fix: resolve mutable borrow error in text plugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:09:10 -03:00
Andrew Phillips
80c6573e71 feat: add text meta plugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 19:05:40 -03:00
Andrew Phillips
06e7e1a616 feat: add text meta plugin 2025-08-26 19:05:35 -03:00
Andrew Phillips
6a6afcfe6e chore: remove redundant debug logging from store_meta
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:54:20 -03:00
Andrew Phillips
e3e9db145d fix: remove duplicate metadata processing in meta service
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:50:20 -03:00
Andrew Phillips
77089c181b fix: remove non-existent FullHostnameMetaPlugin import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:47:01 -03:00
Andrew Phillips
221d8b3b27 feat: add full hostname option to hostname plugin and remove full_hostname plugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:45:14 -03:00
Andrew Phillips
c8afbb3984 feat: implement finalization tracking for system plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:42:14 -03:00
Andrew Phillips
04fb10d006 feat: add finalization tracking to meta plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:37:30 -03:00
Andrew Phillips
56465cdf1d fix: remove duplicate metadata processing in MagicFileMetaPlugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:35:48 -03:00
Andrew Phillips
be60c230b2 fix: add static lifetime bound to MetaPlugin trait
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:26:01 -03:00
Andrew Phillips
b73ba17f80 fix: restrict self to sized types and clone response in process_chunk and finalize_plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:25:00 -03:00
Andrew Phillips
4b66b094d5 feat: add finalization state management for meta plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:23:17 -03:00
Andrew Phillips
bd879100be feat: add finalization state tracking to meta plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:23:02 -03:00
Andrew Phillips
6574b5a072 refactor: rename is_saved to is_finalized in system meta plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:18:10 -03:00
Andrew Phillips
302fe010bd refactor: remove unused item_id fields from meta plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:16:16 -03:00
Andrew Phillips
f5ba5dff2d fix: clone result before moving it into self.result
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 18:12:40 -03:00
Andrew Phillips
0eab6736e1 fix: resolve borrow after move error in program.rs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 18:09:38 -03:00
Andrew Phillips
a2cc0fa071 fix: resolve borrow after move error by cloning result before assignment
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 18:08:32 -03:00
Andrew Phillips
03ac98e219 fix: clone result before moving into self.result
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 18:07:38 -03:00
Andrew Phillips
1d463323ce refactor: update empty hashmap initialization with lazy initialization
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 18:06:50 -03:00
Andrew Phillips
3fb436dc44 fix: add missing meta_name implementation and fix compilation errors
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 18:05:02 -03:00
Andrew Phillips
1f82be1f02 fix: remove unused meta plugin types and references
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 18:03:13 -03:00
Andrew Phillips
e2db93f955 fix: correct for loop iteration and PIPESIZE casting
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 18:02:05 -03:00
Andrew Phillips
9751e7074c fix: remove item_id parameter from MetaPlugin methods and update implementations
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 18:01:37 -03:00
Andrew Phillips
e2b434e52c refactor: update ReadRateMetaPlugin to match new MetaPlugin trait signatures
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:59:52 -03:00
Andrew Phillips
c6c81088b8 fix: update meta plugin implementations to match trait signatures
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:59:16 -03:00
Andrew Phillips
77bd3f09a3 refactor: consolidate user-related plugins into single UserMetaPlugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:55:18 -03:00
Andrew Phillips
bedf000632 refactor: standardize meta plugin structure with base field
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:40:43 -03:00
Andrew Phillips
3fefb1c213 refactor: standardize plugin implementation using base meta plugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:39:37 -03:00
Andrew Phillips
9d53141af7 fix: update binary and system meta plugin implementations
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:34:56 -03:00
Andrew Phillips
293380600e feat: add default implementations for outputs and options methods
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:33:57 -03:00
Andrew Phillips
c6544ab034 refactor: extract magic type processing into helper function
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:30:38 -03:00
Andrew Phillips
f44866a2cc feat: update default is_internal() to return true and clean up overrides
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:27:04 -03:00
Andrew Phillips
a0fcd3f3e7 fix: remove redundant update and default_options methods
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:18:56 -03:00
Andrew Phillips
3cf9d38ae2 refactor: reduce boilerplate by using default implementations and base struct
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:18:23 -03:00
Andrew Phillips
5661b78919 feat: update CwdMetaPlugin to use new MetaPluginResponse interface
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:14:22 -03:00
Andrew Phillips
13ea7159e3 fix: remove unused imports and update method signatures
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:13:51 -03:00
Andrew Phillips
45b57fc547 fix: add missing item_id parameter to process_chunk and finalize_plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:11:55 -03:00
Andrew Phillips
9d60461354 feat: add is_finalized to MetaPluginResponse and remove direct db interaction from meta plugins
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:11:27 -03:00
Andrew Phillips
ec428f5fc4 feat: add default implementations for initialize, update, finalize in MetaPlugin
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 17:05:44 -03:00
Andrew Phillips
56f3c924b3 feat: add META_SERVICE log prefix to all debug and warn messages 2025-08-26 17:05:39 -03:00
Andrew Phillips
4497d9d095 refactor: rename PluginResponse to MetaPluginResponse
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-26 16:21:20 -03:00
Andrew Phillips
13799cd337 refactor: update binary meta plugin to use MetaPluginResponse
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-26 16:19:12 -03:00
Andrew Phillips
a0fcb86fce refactor: update response types to MetaPluginResponse
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-26 16:17:34 -03:00
Andrew Phillips
ec5fde2771 refactor: update meta plugins to use MetaPluginResponse
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-26 16:13:30 -03:00
Andrew Phillips
3021932eb6 feat: add read time tracking functionality
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-26 15:37:35 -03:00
Andrew Phillips
128d98c4e3 feat: update digest and system plugins to return PluginResponse
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-26 15:37:00 -03:00
Andrew Phillips
7b43827926 refactor: update meta plugins to use new trait interface
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-26 15:35:57 -03:00
Andrew Phillips
f48d7b33b8 fix: update warnings to use log::warn
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-26 15:02:31 -03:00
Andrew Phillips
4795a2b2cc fix: add meta service prefix to log messages 2025-08-26 15:02:22 -03:00
Andrew Phillips
e5eadbfc53 feat: handle output name remapping and disabled outputs in plugin initialization
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 14:51:30 -03:00
Andrew Phillips
902c2f9c17 fix: replace eprintln with log::warn for warnings
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 10:03:25 -03:00
Andrew Phillips
6b06b23686 feat: add duplicate output name check for meta plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-26 08:57:56 -03:00
Andrew Phillips
a92c22b58c feat: add ellipsis when truncating strings and only apply max_len for terminal output
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-26 08:45:48 -03:00
Andrew Phillips
2a233b3d43 feat: add support for negative column widths relative to terminal width
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 22:25:22 -03:00
Andrew Phillips
9e133d9527 feat: add terminal width detection using termsize
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 22:22:00 -03:00
Andrew Phillips
689f377865 refactor: move terminal width calculation outside the loop
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 22:19:20 -03:00
Andrew Phillips
0a597d6263 feat: add debug logging for terminal width calculation
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 22:10:53 -03:00
Andrew Phillips
224b5e5976 feat: add support for percentage-based max_len values
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 22:05:36 -03:00
Andrew Phillips
4ea1f248a7 feat: add max_len support to ColumnConfig and default list format
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 22:01:29 -03:00
Andrew Phillips
2cfa06a45b fix: add explicit type annotations for parse and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:49:56 -03:00
Andrew Phillips
e97807f7fa fix: use item service for proper item handling
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:47:06 -03:00
Andrew Phillips
9b525445f3 fix: remove unused variable prefixes and fix scope errors
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:34:35 -03:00
Andrew Phillips
43dac36c39 fix: remove unused variable prefixes in async_item_service
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:32:20 -03:00
Andrew Phillips
58b7cba55a fix: resolve PIPESIZE redefinition and unused variable warnings
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:31:25 -03:00
Andrew Phillips
801445a07c feat: add PIPESIZE constant and re-export in common module
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:30:28 -03:00
Andrew Phillips
781108f6d3 fix: re-export PIPESIZE from lib.rs
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:30:13 -03:00
Andrew Phillips
c52ab9ed5f feat: update default max_buffer_size to use PIPESIZE
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:27:59 -03:00
Andrew Phillips
33b5cc2e92 feat: increase buffer size from PIPESIZE/2 to PIPESIZE
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:27:39 -03:00
Andrew Phillips
6719dff149 feat: replace hardcoded buffer sizes with PIPESIZE constant
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:27:02 -03:00
Andrew Phillips
bf48c37dd8 feat: add PIPESIZE constant for consistent buffer sizes
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:26:33 -03:00
Andrew Phillips
bb9901e9bc fix: properly handle length parameter in stream_item_content_by_id_with_metadata
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:22:40 -03:00
Andrew Phillips
cf76aa8bc2 fix: mark unused variables with underscores
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:18:58 -03:00
Andrew Phillips
564accddfd fix: remove unused variables and content_len
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:18:38 -03:00
Andrew Phillips
bbdbdfa5be fix: unify stream types with trait object
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:17:06 -03:00
Andrew Phillips
bfeba4151e fix: resolve type mismatches in async_item_service and compression_service
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:14:58 -03:00
Andrew Phillips
f3132ec569 fix: resolve type mismatch in async item stream handling
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:13:48 -03:00
Andrew Phillips
c00b6230d4 fix: remove unused imports and fix type mismatches
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:11:40 -03:00
Andrew Phillips
36a53c890c fix: resolve async read and send trait bounds issues
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:10:14 -03:00
Andrew Phillips
9f140923bc fix: resolve private field access and type issues
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:09:13 -03:00
Andrew Phillips
3611c93a4a fix: update imports and correct stream type
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:07:48 -03:00
Andrew Phillips
1640932148 feat: implement streaming for large file handling
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 21:07:04 -03:00
Andrew Phillips
3478ffee2c refactor: optimize item service to reduce redundant database queries
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 20:59:45 -03:00
Andrew Phillips
41ff152a12 fix: clone content for binary check closure
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 20:56:51 -03:00
Andrew Phillips
1fdb08b493 feat: optimize item content streaming to reduce redundant database calls
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 20:55:42 -03:00
Andrew Phillips
5830601150 refactor: add get_item_content_info to AsyncItemService and simplify binary checks
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 20:48:09 -03:00
Andrew Phillips
04554fe04d refactor: extract content info logic to item_service
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 20:46:30 -03:00
Andrew Phillips
52f707d1dd fix: add missing default_allow_binary function
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 20:44:15 -03:00
Andrew Phillips
0932ea9614 chore: remove unused imports and variables
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-25 20:42:31 -03:00
Andrew Phillips
4abb9794e0 fix: add missing item_service argument to AsyncItemService::new
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 20:29:48 -03:00
Andrew Phillips
1f11351d9b refactor: optimize item service creation by creating it once per connection
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 20:27:21 -03:00
Andrew Phillips
7eefb64d15 refactor: extract shared content streaming logic into helper function
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 20:23:18 -03:00
Andrew Phillips
ad1064ec02 feat: optimize item content retrieval to reduce redundant database queries
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 20:20:29 -03:00
Andrew Phillips
4acec3d3dd refactor: optimize item content streaming to reduce redundant database queries
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 20:17:45 -03:00
Andrew Phillips
b9d6bd52d5 feat: implement streaming for item content with offset and length support
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 19:33:24 -03:00
Andrew Phillips
d03712874b fix: log correct response body size in logging middleware
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 19:13:19 -03:00
Andrew Phillips
6ee1c64080 feat: implement length parameter handling in stream_item_content_by_id
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 19:11:03 -03:00
Andrew Phillips
694575ad36 fix: replace streaming with synchronous content retrieval
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 19:06:48 -03:00
Andrew Phillips
b039bc4b33 fix: remove unused imports and fix field access errors
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 19:05:33 -03:00
Andrew Phillips
4dc4d89f81 feat: add decompression support for streaming item content
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 19:04:11 -03:00
Andrew Phillips
cbb5af6ea5 fix: remove incorrect .dat extension from async item file path
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:57:18 -03:00
Andrew Phillips
7ef2ac670b fix: resolve lifetime issues and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:50:21 -03:00
Andrew Phillips
e364bd072a refactor: move stream_item_content to AsyncItemService as method
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:49:28 -03:00
Andrew Phillips
f552c978e0 fix: update stream_item_content return type to tokio_util::bytes::Bytes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:48:21 -03:00
Andrew Phillips
62844b2073 fix: update bytes import and fix data_path field references
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:47:54 -03:00
Andrew Phillips
81307bfe19 fix: add bytes dependency and fix stream type mismatches
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:46:15 -03:00
Andrew Phillips
a814f60f32 feat: add stream_item_content method for async content streaming
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:42:54 -03:00
Andrew Phillips
10b1522095 refactor: move allow_binary logic to async_item_service
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:42:31 -03:00
Andrew Phillips
c3dd017b25 feat: use item content for binary detection
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:39:59 -03:00
Andrew Phillips
06c6971d07 feat: add streaming content support with offset and length parameters
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:33:22 -03:00
Andrew Phillips
fd61b66a97 feat: implement allow_binary content filtering for item endpoints
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:31:20 -03:00
Andrew Phillips
71b29d1def chore: remove unused anyhow error import and server password fields
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:28:23 -03:00
Andrew Phillips
ad12b552a0 fix: remove unused KeyValue struct and related fields
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:27:49 -03:00
Andrew Phillips
f6454d94f3 refactor: remove unused code and fields
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:26:01 -03:00
Andrew Phillips
cd4838a474 fix: remove incorrect downcast_ref error handling
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:22:40 -03:00
Andrew Phillips
321e00171e feat: move core services to services directory 2025-08-25 18:22:17 -03:00
Andrew Phillips
8cc0cfc606 fix: replace matches with pattern matching for CoreError handling
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:21:56 -03:00
Andrew Phillips
6c00c2ab56 fix: handle CoreError downcasting properly in error handling
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:20:14 -03:00
Andrew Phillips
c49a63bb10 fix: remove unused imports and add comment for error handling
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:19:45 -03:00
Andrew Phillips
ff91e7051f fix: remove unused imports and fix stream handling
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:19:11 -03:00
Andrew Phillips
97c4e26dbf feat: improve binary detection and streaming for item content
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:16:56 -03:00
Andrew Phillips
90dd6d7718 feat: add streaming support to /content endpoints with offset and length parameters
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:16:36 -03:00
Andrew Phillips
5db2e9c063 refactor: remove item latest and get handlers
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:09:46 -03:00
Andrew Phillips
b3a21d1f7c refactor: remove item/latest and item/{item_id} routes and handlers
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 18:09:20 -03:00
Andrew Phillips
b2108f2369 feat: add MCP route to server
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 17:52:47 -03:00
Andrew Phillips
dc8ed09b06 feat: dynamically generate supported meta plugins list
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 17:50:10 -03:00
Andrew Phillips
0fe61de89e fix: remove unused anyhow import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 17:49:10 -03:00
Andrew Phillips
456430b36e fix: remove incorrect anyhow::Result import and redundant impl block
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 16:53:00 -03:00
Andrew Phillips
b015a63f04 fix: correct KeepMcpServer import path and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 16:52:49 -03:00
Andrew Phillips
dd08722cfc feat: implement mcp server request handling
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-25 16:51:55 -03:00
Andrew Phillips
aa66bfca06 fix: remove redundant debug log and metadata retrieval
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-25 15:55:05 -03:00
Andrew Phillips
7d7b2d74fe feat: make save_item return only item ID for CLI usage
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-25 15:52:55 -03:00
Andrew Phillips
9d99273ff7 feat: move new item notification before input processing
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-25 15:47:41 -03:00
Andrew Phillips
a7b142945b feat: add debugging to item service
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-25 15:43:56 -03:00
Andrew Phillips
078d90b723 feat: add item service for managing items and their metadata 2025-08-25 15:43:44 -03:00
Andrew Phillips
378d42c2af fix: add services module and fix type annotation error
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-25 15:32:26 -03:00
Andrew Phillips
a203059bb4 refactor: rename core module to services
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 14:21:20 -03:00
Andrew Phillips
08ac832da4 fix: correct destructuring of RusqliteError variant
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 13:16:29 -03:00
Andrew Phillips
ff85bb611b fix: resolve borrow of partially moved value errors and update error variant
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 13:13:46 -03:00
Andrew Phillips
1880d1059e fix: remove unused mutability from meta_plugin variables
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 13:10:52 -03:00
Andrew Phillips
ee0545b739 fix: resolve ownership and borrowing errors and add serde traits
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 13:06:54 -03:00
Andrew Phillips
53c63360cb fix: update module declarations and imports
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 13:03:59 -03:00
Andrew Phillips
a1bcba5cb1 fix: fix async database locking and diff process order
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 12:59:04 -03:00
Andrew Phillips
afe23aaa40 feat: add save_item_from_mcp functionality to core services
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 12:48:10 -03:00
Andrew Phillips
da59401ca7 chore: mark REST API refactoring as complete
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 12:44:04 -03:00
Andrew Phillips
f7cbf776ae refactor: update api handlers to use async item service
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 12:41:45 -03:00
Andrew Phillips
7700026d87 feat: add async item service wrapper
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 12:37:18 -03:00
Andrew Phillips
7b2fb257eb chore: mark CLI modes refactoring as complete
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 12:35:18 -03:00
Andrew Phillips
a0c8363852 refactor: update diff mode to use ItemService and ItemWithMeta
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 12:31:36 -03:00
Andrew Phillips
89fa9bee6f chore: update plan with implemented changes
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-25 00:05:35 -03:00
Andrew Phillips
7a5bcf2722 refactor: update info mode to use ItemService
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-24 23:58:50 -03:00
Andrew Phillips
7ec0603e00 feat: implement core services and refactor modes
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-24 23:56:06 -03:00
Andrew Phillips
437a05e5d6 feat: add core module with essential services and types 2025-08-24 23:55:38 -03:00
Andrew Phillips
e66c7127ac chore: reorder refactoring plan for logical flow
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-24 23:48:40 -03:00
Andrew Phillips
a5992fdb38 docs: update PLAN.md with file and function change details
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-24 23:45:43 -03:00
Andrew Phillips
19e1ae587f docs: update refactoring plan with thread safety, error handling, and layer boundaries
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-24 23:36:55 -03:00
Andrew Phillips
0f79d99c8b feat: update plan with async/sync boundaries and performance optimizations
Co-authored-by: aider (openai/andrew/openrouter/mistralai/mistral-medium-3.1) <aider@aider.chat>
2025-08-24 23:20:37 -03:00
Andrew Phillips
accb5b79f8 docs: add refactoring plan to reduce code duplication
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-23 13:15:56 -03:00
Andrew Phillips
c7640e3fd9 docs: update test refactoring plan 2025-08-23 13:15:54 -03:00
Andrew Phillips
0f156770f6 fix: resolve borrow and move errors and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/deepseek/deepseek-chat-v3.1) <aider@aider.chat>
2025-08-23 13:06:04 -03:00
Andrew Phillips
fb40809078 feat: add missing database functions and fix tool errors
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-23 13:01:30 -03:00
Andrew Phillips
4d87a9822b build: update and deduplicate dependencies 2025-08-23 13:01:11 -03:00
Andrew Phillips
925c978bbc feat: add Model Context Protocol (MCP) SSE endpoint
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-23 12:57:00 -03:00
Andrew Phillips
f2eabd65b0 feat: add rmcp dependency with server feature 2025-08-23 12:56:41 -03:00
Andrew Phillips
449e47f721 fix: resolve stack overflow in meta plugin initialization by removing recursive calls
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-23 12:40:37 -03:00
Andrew Phillips
670d078eae fix: initialize final_options in meta plugin constructors
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 14:20:45 -03:00
Andrew Phillips
a3494ee831 feat: add options to meta plugins
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 14:18:59 -03:00
Andrew Phillips
107a1f3eb4 feat: add debug logs for status mode stack overflow investigation
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 14:14:21 -03:00
Andrew Phillips
73bfc064ea refactor: change meta_name to immutable reference and add debugging
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 14:12:53 -03:00
Andrew Phillips
eb7da379ef fix: prevent stack overflow in status mode and remove unused variables
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 14:11:18 -03:00
Andrew Phillips
d4359be92f fix: remove problematic meta plugin initialization in status mode
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 14:05:47 -03:00
Andrew Phillips
971c24af2d fix: replace default() with new_simple() for MagicFileMetaPlugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 14:03:49 -03:00
Andrew Phillips
4371366db4 fix: update method calls to use instance methods
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 14:00:15 -03:00
Andrew Phillips
15f522a218 fix: correct closing brace syntax errors
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:58:28 -03:00
Andrew Phillips
98c67e0e82 fix: remove extra closing braces in meta plugin implementations
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:57:50 -03:00
Andrew Phillips
c0da7ae086 fix: remove unexpected closing brace in MetaPluginProgram
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:57:26 -03:00
Andrew Phillips
81397c1319 feat: update meta plugin constructors to accept options and outputs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:56:33 -03:00
Andrew Phillips
b4d40f01e8 fix: initialize meta plugins with defaults before configuration
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:46:13 -03:00
Andrew Phillips
c2d724e6cc fix: use configured outputs for meta plugins in status
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:44:26 -03:00
Andrew Phillips
75b6c56dcc fix: remove unused config import and fix borrow-after-move error
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:41:17 -03:00
Andrew Phillips
36159d2fb9 fix: use correct output names in default outputs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:39:52 -03:00
Andrew Phillips
cd6033ad3b feat: add default_options method to all meta plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:36:59 -03:00
Andrew Phillips
c7a0e285e0 feat: add default outputs to meta plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:35:49 -03:00
Andrew Phillips
a1a66c1920 fix: remove unused imports and fix settings default error
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:33:10 -03:00
Andrew Phillips
63eb1b10b3 feat: add MetaPlugin.configure method and update status generation
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-19 13:32:21 -03:00
Andrew Phillips
fddc7670aa chore: remove unused update mode 2025-08-19 13:31:13 -03:00
Andrew Phillips
afb3d789ba feat: add default outputs display for meta plugins
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 13:23:51 -03:00
Andrew Phillips
5ca66d7469 feat: add MetaPlugin outputs to status display
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 13:19:57 -03:00
Andrew Phillips
2b79c6380f refactor: split configure into configure_options and configure_outputs methods
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 13:17:27 -03:00
Andrew Phillips
38cbf06579 refactor: replace get_* and set_* methods with direct field access
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 13:15:48 -03:00
Andrew Phillips
58ecbd63cf refactor: update meta plugin to use output mappings
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 12:57:06 -03:00
Andrew Phillips
1659bf20d4 fix: handle buffer capacity check correctly 2025-08-19 12:56:45 -03:00
Andrew Phillips
9aa76857b0 fix: configure meta plugins with options from settings including binary plugin max_buffer_size
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-19 12:38:06 -03:00
Andrew Phillips
a46bf3364a refactor: remove unused binary plugin finalization logic 2025-08-19 12:36:24 -03:00
Andrew Phillips
ff5d233509 refactor: improve buffer handling logic and comments
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 20:54:42 -03:00
Andrew Phillips
cec6081218 fix: remove deprecated max_buffer option support 2025-08-18 20:54:40 -03:00
Andrew Phillips
d19ef19a5b feat: implement buffer size limit and early metadata storage for binary plugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 20:39:52 -03:00
Andrew Phillips
4ba8ce74cb refactor: move binary plugin to its own module
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 20:30:33 -03:00
Andrew Phillips
40637c8881 feat: add binary meta plugin implementation 2025-08-18 20:30:29 -03:00
Andrew Phillips
bc2ebaa314 fix: remove references to removed 'update' mode in argument definitions
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 20:16:47 -03:00
Andrew Phillips
3c6df7334a refactor: remove update mode and related references
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 20:15:39 -03:00
Andrew Phillips
c1daf510f8 feat: pass connection to meta plugin methods
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 20:14:00 -03:00
Andrew Phillips
d96804bdfb refactor: remove connection storage from plugin structs and pass as argument
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 15:52:27 -03:00
Andrew Phillips
2a16edcbe7 fix: remove unused magic_file metadata outputs and fix binary detection timing
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 11:08:48 -03:00
Andrew Phillips
7f28129c00 feat: add central metadata output handler
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 10:51:58 -03:00
Andrew Phillips
ecd3e316c5 fix: correct type mismatch in max_buffer_size handling
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 10:09:45 -03:00
Andrew Phillips
592c277735 feat: add support for meta plugin options and outputs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 10:07:51 -03:00
Andrew Phillips
15774d377d fix: remove unused digest field and mutable process
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 09:54:02 -03:00
Andrew Phillips
37b0d0e3b0 chore: remove --digest argument and $KEEP_DIGEST environment variable
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 09:50:23 -03:00
Andrew Phillips
05b2c1b9bd feat: update metadata field names to use mime_type and mime_encoding 2025-08-18 09:44:27 -03:00
Andrew Phillips
18a3f1b36e feat: add sha256 hash metadata storage in finalize
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 09:20:07 -03:00
Andrew Phillips
9e62356a30 feat: initialize cookie once and use set_flags for subsequent operations
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 09:16:40 -03:00
Andrew Phillips
e86fbda144 feat: rename magic meta fields to remove prefix 2025-08-18 09:16:37 -03:00
Andrew Phillips
1b6ff44312 fix: correct magic file type and mime encoding detection
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:50:01 -03:00
Andrew Phillips
1e4e039672 feat: add combined mime info support and remove metadata filtering
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:47:04 -03:00
Andrew Phillips
ffd5fb12cc fix: initialize magic cookie with mime type and encoding flags
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:44:21 -03:00
Andrew Phillips
8de8368df7 feat: implement program plugin metadata saving
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:40:36 -03:00
Andrew Phillips
c89fe9bef3 fix: add std::io imports and update finalize return type
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:35:01 -03:00
Andrew Phillips
e9aaa5d5bf fix: update digest handling in update mode
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:33:46 -03:00
Andrew Phillips
cd63dda271 fix: update trait and remove unused io imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:31:47 -03:00
Andrew Phillips
22206de5ab feat: update finalize to return Result<()> and simplify save mode
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:12:49 -03:00
Andrew Phillips
f11070dc60 feat: implement buffer-based storage for magic file metadata
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:08:27 -03:00
Andrew Phillips
2424c543d6 fix: implement debug for meta plugin program and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:03:55 -03:00
Andrew Phillips
3dac2d3073 fix: remove deprecated create method from MetaPlugin implementations
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:03:06 -03:00
Andrew Phillips
2b6f068784 refactor: remove unused create() method from MetaPlugin trait
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-18 08:00:24 -03:00
Andrew Phillips
c38ae0d4a9 feat: add max_buffer_size to MagicFileMetaPlugin and refactor MetaPluginProgram
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-18 07:56:56 -03:00
Andrew Phillips
133538881f feat: combine magic plugins into single magic_file plugin
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-17 17:36:46 -03:00
Andrew Phillips
51453db3c3 fix: specify type parameter for cookie.load
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-16 14:53:11 -03:00
Andrew Phillips
7615d6af88 fix: update magic cookie flags and default value
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-16 14:52:35 -03:00
Andrew Phillips
ee01f7823f fix: add magic module declaration to meta_plugin
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-16 14:52:03 -03:00
Andrew Phillips
151ec151db feat: add magic meta plugin implementation
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-16 14:51:30 -03:00
Andrew Phillips
28959a357c feat: add magic plugin and update debug log format 2025-08-16 14:51:26 -03:00
Andrew Phillips
ec3ef25f38 feat: add magic meta plugin with file type detection
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-16 14:49:00 -03:00
Andrew Phillips
31a653449c fix: prevent duplicate metadata storage by returning empty strings from finalize
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-16 14:37:13 -03:00
Andrew Phillips
6b2cb49881 fix: add is_saved tracking to prevent duplicate metadata saves
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-16 14:32:40 -03:00
Andrew Phillips
4f05dbd61f fix: prevent duplicate metadata saves and fix binary plugin detection
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:29:31 -03:00
Andrew Phillips
c23edf0fb8 refactor: rename saved_during_io to is_saved
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:26:32 -03:00
Andrew Phillips
9dea6dec4e refactor: remove redundant save_meta implementation from BinaryMetaPlugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:26:02 -03:00
Andrew Phillips
e8c9eda1fa fix: add missing log::debug import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:24:35 -03:00
Andrew Phillips
cfee32ff35 fix: remove saved_during_io from trait and add to BinaryMetaPlugin
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:24:06 -03:00
Andrew Phillips
389bb59531 feat: add early binary detection and prevent duplicate metadata saving
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:23:28 -03:00
Andrew Phillips
9fa0dedb42 fix: add missing log::debug import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:14:30 -03:00
Andrew Phillips
af2fe02806 feat: add debug logging to save_meta function
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:14:03 -03:00
Andrew Phillips
ead7bfcb33 fix: correct get mode detection logic
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:09:26 -03:00
Andrew Phillips
b4c9fd47c2 fix: change self reference to mutable in save_meta and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:06:04 -03:00
Andrew Phillips
a7977139a7 refactor: move meta plugin finalization logic into meta plugins
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 14:04:19 -03:00
Andrew Phillips
4f61306d79 fix: fix cell alignment method chaining issue
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 13:44:53 -03:00
Andrew Phillips
db808bb794 fix: add align field to ColumnConfig and fix cell creation functions
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 13:43:55 -03:00
Andrew Phillips
65dd800526 feat: add support for left/right alignment in list_format columns
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 13:42:12 -03:00
Andrew Phillips
21e8eb1d09 fix: replace home crate with std::env::var for home directory resolution
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 13:39:11 -03:00
Andrew Phillips
d9dc72e3e1 feat: replace dirs crate with home::home_dir for config path resolution
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 13:38:28 -03:00
Andrew Phillips
07c579af94 fix: implement default config path logic and remove unused variable warning
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 13:38:00 -03:00
Andrew Phillips
09ec19fcab fix: use configured labels for meta columns in list display
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 13:37:05 -03:00
Andrew Phillips
61ece03aa3 fix: remove deprecated default_config_path function
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 13:35:14 -03:00
Andrew Phillips
5897f89a76 fix: fix handling of meta:* columns and labels
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 13:09:49 -03:00
Andrew Phillips
15e2103f66 feat: handle meta:<name> column type pattern
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:55:38 -03:00
Andrew Phillips
6cb050188e feat: ignore all empty environment variables
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:52:06 -03:00
Andrew Phillips
1eca639c19 fix: ignore empty KEEP_LIST_FORMAT environment variable
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:51:11 -03:00
Andrew Phillips
9fc645c54a feat: add default label to name for YAML column config
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:48:10 -03:00
Andrew Phillips
8a2e992ca5 fix: update default list_format labels to match their names
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:47:45 -03:00
Andrew Phillips
c470e63bac feat: add debug logging and make dir field optional in settings
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:42:31 -03:00
Andrew Phillips
1c6064fdb7 refactor: remove unused struct members and methods
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:32:11 -03:00
Andrew Phillips
014dd380cd feat: add new dependencies to Cargo.lock 2025-08-16 12:30:37 -03:00
Andrew Phillips
cb4a0c877d fix: remove reference to deleted handle_delete_item function
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:30:06 -03:00
Andrew Phillips
a00952a377 fix: remove unused delete_item handler and add accessors for unused fields
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:29:32 -03:00
Andrew Phillips
9f5f999989 fix: implement PartialEq for OutputFormat and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:28:31 -03:00
Andrew Phillips
1145f637c7 feat: add OutputFormat enum and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:28:13 -03:00
Andrew Phillips
172c7ec91d refactor: remove unused functions and output format enum
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:27:32 -03:00
Andrew Phillips
79d704c1cd fix: make AppState password fields public
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:25:23 -03:00
Andrew Phillips
dfd855f380 fix: make unused fields public and remove pub(crate) visibility
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:25:04 -03:00
Andrew Phillips
5e111e002a style: make verbose fields private
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:23:53 -03:00
Andrew Phillips
3de5947d42 refactor: make unused fields and functions private
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:23:20 -03:00
Andrew Phillips
c3f4e03f33 refactor: remove unused fields and functions
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:22:59 -03:00
Andrew Phillips
ad1a7e44bc fix: add missing FromStr trait import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:22:25 -03:00
Andrew Phillips
969d30924b fix: remove unused imports and make unused fields private
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:21:57 -03:00
Andrew Phillips
8b8868760c fix: clone db_path before passing to db::open
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:19:32 -03:00
Andrew Phillips
12e0fa9aea fix: replace init_db with open in database initialization
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:16:34 -03:00
Andrew Phillips
3999baf8eb fix: fix unused imports and db initialization error
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:15:27 -03:00
Andrew Phillips
270b3c711e fix: remove unused std::str::FromStr imports and fix ValueKind conversion errors
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:13:54 -03:00
Andrew Phillips
0feb8f574a feat: add database initialization and mode handling logic
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:12:55 -03:00
Andrew Phillips
5c66ac3d8a fix: remove unused config parameter in mode functions
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:11:31 -03:00
Andrew Phillips
3f13e490b3 refactor: remove meta data handling from update mode
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:05:18 -03:00
Andrew Phillips
c4b2a5d7e7 fix: update config type references and method calls
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 12:03:13 -03:00
Andrew Phillips
28b7199424 chore: remove deprecated tests.rs file 2025-08-16 11:57:10 -03:00
Andrew Phillips
b90456c6f4 feat: update config system and list format structure
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-16 11:57:02 -03:00
Andrew Phillips
e1091b72f8 chore: update pwhash dependency and add settings debug log 2025-08-16 11:56:52 -03:00
Andrew Phillips
2764f16c20 feat: add support for salted password hash authentication
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 21:03:12 -03:00
Andrew Phillips
fd5681b630 feat: add support for password hashing authentication
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 20:49:56 -03:00
Andrew Phillips
0e88a177b3 feat: add support for direct password configuration in server settings
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 20:38:09 -03:00
Andrew Phillips
7ad10f92e8 fix: remove unused imports and dead code
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 17:58:59 -03:00
Andrew Phillips
e880da210a fix: add server address and port fields to settings and update constructor
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 17:57:59 -03:00
Andrew Phillips
421fd76fe6 refactor: remove unix socket support fallback
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 17:55:50 -03:00
Andrew Phillips
29ce003fa8 feat: implement server configuration with address and port options
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 17:55:13 -03:00
Andrew Phillips
b9d79d2fe1 fix: remove unused variable and add missing FromStr import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 17:11:49 -03:00
Andrew Phillips
898622ff6c feat: add compression type support to status endpoint
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 17:10:59 -03:00
Andrew Phillips
0a5bf38359 feat: update compression status to show enabled state
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 17:10:08 -03:00
Andrew Phillips
005937236b feat: add config module to lib exports
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-15 16:51:17 -03:00
Andrew Phillips
b6389419c0 fix: remove unused imports and resolve config module conflicts
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-15 16:39:11 -03:00
Andrew Phillips
56f4d8aad5 feat: implement unified settings system
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-15 16:36:58 -03:00
Andrew Phillips
067cba703b feat: add config system with --config argument and priority-based configuration
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-15 16:31:57 -03:00
Andrew Phillips
5689c3e5ef feat: add config module 2025-08-15 16:31:53 -03:00
Andrew Phillips
20c2716915 docs: update DESIGN.md with current application state
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 13:20:53 -03:00
Andrew Phillips
d7c2abb43d chore: remove unused test helpers
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 13:09:38 -03:00
Andrew Phillips
31cb235023 refactor: replace direct file existence checks with test helpers
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 13:07:15 -03:00
Andrew Phillips
d82a1c0414 fix: escape reserved keyword 'gen' in test helper function
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 13:02:24 -03:00
Andrew Phillips
26efd30436 fix: resolve compilation errors by fixing reserved keyword usage and removing unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 13:01:46 -03:00
Andrew Phillips
5e5a59d960 fix: resolve rand crate dependency and fix reserved keyword usage in tests
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 13:00:32 -03:00
Andrew Phillips
2f78e45444 feat: add helper functions for creating temporary files with random binary content and asserting file sizes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 11:26:25 -03:00
Andrew Phillips
60ec6da886 feat: add populated test database and directory helper functions
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 11:26:06 -03:00
Andrew Phillips
23681240d8 feat: add binary file helpers and file assertion utilities to test module
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 11:25:44 -03:00
Andrew Phillips
b9438f2791 feat: add test helpers for database setup and file content comparison
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 11:25:19 -03:00
Andrew Phillips
5d44be21fa feat: add compression engine test helpers and refactor tests
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 11:24:38 -03:00
Andrew Phillips
d5c956e626 feat: create common test helpers to reduce duplication across test modules
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 11:23:17 -03:00
Andrew Phillips
d0e62ad980 fix: update test to correctly handle xz compression type support
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 11:11:38 -03:00
Andrew Phillips
706e5c29ea fix: correct test assertions and database foreign key references
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-15 11:11:23 -03:00
Andrew Phillips
d194ae1edf fix: make plugin mutable in test to fix borrow error
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-14 17:15:00 -03:00
Andrew Phillips
ff1d4f164a fix: resolve compilation errors in tests by fixing type mismatches and imports
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-14 17:13:48 -03:00
Andrew Phillips
67af475339 fix: resolve import errors in test modules and remove unused variables
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-14 17:11:27 -03:00
Andrew Phillips
acbeb297b2 fix: enable and fix all existing tests by updating module imports and test implementations
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-14 17:09:13 -03:00
Andrew Phillips
efa51b1a6b fix: add Debug derive to KeepModes and remove unused import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 17:06:29 -03:00
Andrew Phillips
e962c4857a fix: restore KeepModes enum and fix unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 17:05:16 -03:00
Andrew Phillips
2dfaed38b8 fix: implement FromStr for NumberOrString and KeyValue to fix clap parsing errors
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 17:03:55 -03:00
Andrew Phillips
6af1ac30df fix: resolve import issues for Args and ProgramWriter in tests
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 13:04:22 -03:00
Andrew Phillips
a0e79bc90a fix: remove unnecessary cfg(test) attributes from test modules
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 12:27:25 -03:00
Andrew Phillips
2713f2b127 fix: configure tests to run by removing deprecated test config and adding missing import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 12:26:28 -03:00
Andrew Phillips
24d7c4742c chore: update dependencies and remove unused test modules 2025-08-14 12:23:46 -03:00
Andrew Phillips
0abb76e785 feat: implement comprehensive tests for all modules including database, meta plugins, compression engines, modes, server auth, and utilities to complete Phase 2
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 12:18:36 -03:00
Andrew Phillips
4e23dd36e1 feat: implement Phase 2 test structure and modules
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 12:15:57 -03:00
Andrew Phillips
8284545ca7 fix: update test files to use correct compression engine initialization and add compression_types tests
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 12:04:26 -03:00
Andrew Phillips
fde369c5d9 fix: resolve import paths in test files and remove deprecated tests.rs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 12:00:50 -03:00
Andrew Phillips
9142cdde2d refactor: split compression tests into separate module files as planned
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 11:57:56 -03:00
Andrew Phillips
9e10b1b497 docs: add plan for refactoring tests.rs into multiple files
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 11:51:17 -03:00
Andrew Phillips
027fa10f04 chore: remove completed items from PLAN.md 2025-08-14 11:51:13 -03:00
Andrew Phillips
0eafdd0985 fix: resolve compression_engine import paths in tests
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 11:46:03 -03:00
Andrew Phillips
87a1628bbe fix: resolve import paths in tests and remove unused import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 11:45:34 -03:00
Andrew Phillips
1a9a21e321 test: refactor and expand compression engine tests with better organization and coverage
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-14 11:41:05 -03:00
Andrew Phillips
81005ec8f4 test: add compression engine tests and refactor test structure 2025-08-14 11:40:59 -03:00
Andrew Phillips
d14857fa47 fix: remove handle_ prefix from OpenAPI operation IDs and add missing delete endpoint
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-13 13:43:28 -03:00
Andrew Phillips
92e589699c fix: resolve utoipa schema generation errors by removing unsupported description attributes and creating specific response types
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-13 13:39:07 -03:00
Andrew Phillips
87e76f6314 docs: Add comprehensive descriptions to all OpenAPI documentation endpoints and schemas
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-13 13:37:42 -03:00
Andrew Phillips
243e77fba4 feat: add optional allow_binary parameter to item handlers
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-13 13:25:27 -03:00
Andrew Phillips
07ea7ec5a4 fix: remove invalid security_schemes attribute and fix OpenAPI schema references
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-13 13:21:43 -03:00
Andrew Phillips
b07c9df812 feat: enhance HTTP logging and API responses with content metadata
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-13 13:20:57 -03:00
Andrew Phillips
8bd918129c docs: update API documentation tag name from 'items' to 'item' 2025-08-13 13:20:54 -03:00
Andrew Phillips
20a227fc9e fix: resolve overlapping route and deprecated base64 decode usage
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-13 13:05:14 -03:00
Andrew Phillips
50150ce23d feat: implement centralized logging and authentication middleware
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-13 13:03:13 -03:00
Andrew Phillips
1170c5ee47 feat: implement centralized HTTP request logging and authentication middleware with bearer and basic auth support
Co-authored-by: aider (openai/andrew/openrouter/anthropic/claude-sonnet-4) <aider@aider.chat>
2025-08-13 13:00:15 -03:00
Andrew Phillips
b380930493 fix: correct swagger and openapi.json endpoints paths
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 12:47:08 -03:00
Andrew Phillips
d8e864c353 fix: remove duplicate docs route registration to prevent panic
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-13 12:43:14 -03:00
Andrew Phillips
8501154084 feat: implement OpenAPI documentation with utoipa and Swagger UI integration
Co-authored-by: aider (openai/andrew/openrouter/google/gemini-2.5-pro) <aider@aider.chat>
2025-08-13 12:35:11 -03:00
Andrew Phillips
c7a843d9ed fix: remove invalid openapi attribute parameter
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:58:36 -03:00
Andrew Phillips
969f05c7d2 fix: add OpenAPI version field to resolve rendering issue
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:57:23 -03:00
Andrew Phillips
23af220f9c feat: add missing delete item endpoint to OpenAPI specification
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:55:32 -03:00
Andrew Phillips
733bd780d5 fix: remove invalid body parameter from delete item API documentation
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:49:59 -03:00
Andrew Phillips
47e901afa0 fix: add missing generic parameter to ApiResponse in delete item endpoint documentation
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:49:32 -03:00
Andrew Phillips
5e615315f5 fix: remove generic type from ApiResponse in delete item endpoint documentation
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:48:49 -03:00
Andrew Phillips
6dd1cbccc7 fix: correct OpenAPI annotation for delete item endpoint
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:47:31 -03:00
Andrew Phillips
733ef6332a fix: update route parameter syntax from :item_id to {item_id}
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:44:42 -03:00
Andrew Phillips
800fad89ea fix: correct API path documentation and remove missing delete handler
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:42:13 -03:00
Andrew Phillips
6bfb5ed963 fix: resolve OpenAPI macro and file descriptor type mismatches
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:40:44 -03:00
Andrew Phillips
99949cf1ee fix: resolve compilation errors in API paths and file descriptor handling
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:22:46 -03:00
Andrew Phillips
6c9074f1be fix: reorder OpenAPI paths and fix file descriptor handling in diff mode
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:21:14 -03:00
Andrew Phillips
e66af572d3 fix: resolve compilation errors in API paths and file descriptor handling
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:20:05 -03:00
Andrew Phillips
baf92a8902 fix: resolve compilation errors in API routes and diff pipe setup
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:19:05 -03:00
Andrew Phillips
9f6ab94992 fix: remove duplicate code in handle_delete_item and fix path parameter syntax
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:09:09 -03:00
Andrew Phillips
d5ee57863a fix: resolve compilation errors in API paths, file descriptors, and router merging
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:08:37 -03:00
Andrew Phillips
93a4d5b2bd fix: resolve rusqlite version conflict with rusqlite_migration
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 11:01:09 -03:00
Andrew Phillips
138ebafbb5 chore: update dependencies and reorder Cargo.toml entries 2025-08-13 11:01:06 -03:00
Andrew Phillips
371929c127 fix: add .into() to convert SwaggerUi to Router in add_docs_routes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 10:05:12 -03:00
Andrew Phillips
ecbc9bd14a fix: remove incorrect type annotation in merge call for SwaggerUi integration
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 10:04:49 -03:00
Andrew Phillips
e98ceb7f7e fix: remove unnecessary .into() call in add_docs_routes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 10:04:31 -03:00
Andrew Phillips
96901ef29b fix: add explicit type annotation for merge method in add_docs_routes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 10:04:14 -03:00
Andrew Phillips
324e109c57 fix: add missing .into() call to convert SwaggerUi to Router in add_docs_routes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 10:03:56 -03:00
Andrew Phillips
94888c078a fix: remove unnecessary type annotation and .into() call in add_docs_routes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 10:03:08 -03:00
Andrew Phillips
288b53404c fix: remove unused imports and add type annotation for Swagger UI merge
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-13 10:01:51 -03:00
Andrew Phillips
3767d9f607 fix: resolve Swagger UI merge compilation error by converting to Router
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 17:27:34 -03:00
Andrew Phillips
c47bacf6c5 style: fix indentation in add_docs_routes function
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 17:10:14 -03:00
Andrew Phillips
036707957c fix: resolve Swagger UI integration with Axum router
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 17:10:02 -03:00
Andrew Phillips
8207c29d9c fix: add missing db module import and fix router merge issue
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 17:09:12 -03:00
Andrew Phillips
4d3a9fd3ac fix: resolve compilation errors by adding missing imports and fixing schema definitions
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:58:29 -03:00
Andrew Phillips
3395c54843 refactor: reformat route addition for better readability 2025-08-12 16:58:27 -03:00
Andrew Phillips
900aa73959 fix: add missing utoipa dependencies for API documentation
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:57:16 -03:00
Andrew Phillips
47f4c1b865 fix: remove duplicate once_cell dependency in Cargo.toml 2025-08-12 16:57:15 -03:00
Andrew Phillips
3a999e60f8 fix: remove redundant OpenAPI endpoint and fix Swagger UI path reference
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:55:33 -03:00
Andrew Phillips
27a590a566 fix: restore missing OpenAPI handler and fix Swagger UI route reference
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:55:14 -03:00
Andrew Phillips
01b27fb61d fix: restore openapi.json endpoint and update swagger UI path reference
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:54:58 -03:00
Andrew Phillips
454bf7ba4a fix: remove trailing slash from swagger UI route 2025-08-12 16:54:57 -03:00
Andrew Phillips
90d4f3f10b fix: add OpenAPI documentation to API endpoints and integrate Swagger UI
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:47:16 -03:00
Andrew Phillips
96bfc09c51 feat: add swagger documentation link to main page
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:40:09 -03:00
Andrew Phillips
d4c3f5a090 feat: add HTML endpoints for item listing and details pages
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:37:15 -03:00
Andrew Phillips
6ec8e7a669 feat: add server pages mode implementation 2025-08-12 16:37:13 -03:00
Andrew Phillips
6e4b690bd8 feat: use humansize crate and which crate for program lookup
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:23:11 -03:00
Andrew Phillips
465e4c40ab refactor: replace custom isatty implementation with is-terminal crate
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:18:53 -03:00
Andrew Phillips
db8be3e480 build: add once_cell dependency to Cargo.toml
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:18:12 -03:00
Andrew Phillips
d0eecc94f2 perf: Cache program lookups to reduce filesystem operations
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:18:07 -03:00
Andrew Phillips
225f6b24b2 fix: remove unused StatusInfo import in status.rs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:01:30 -03:00
Andrew Phillips
1922a08742 fix: resolve unresolved imports by exposing common modules
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:00:58 -03:00
Andrew Phillips
900f8cbc90 fix: correct import paths for common modules
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:00:29 -03:00
Andrew Phillips
9ef94ea291 fix: correct import paths from crate::common to crate::modes::common
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 16:00:05 -03:00
Andrew Phillips
3e865660e4 fix: remove duplicate mod declaration and add missing common module file
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:59:07 -03:00
Andrew Phillips
dceadd585a fix: improve UTF-16 detection logic in is_binary function
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:58:14 -03:00
Andrew Phillips
16644bb9a6 feat: add binary file detection with signature matching and text analysis 2025-08-12 15:58:11 -03:00
Andrew Phillips
3675a64a16 refactor: update imports for is_binary module move
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:57:01 -03:00
Andrew Phillips
bd1d6d00c3 refactor: Add SERVER prefix to debug log message in server mode 2025-08-12 15:54:05 -03:00
Andrew Phillips
5a8420f7a1 refactor: remove unused common module 2025-08-12 15:53:42 -03:00
Andrew Phillips
9b80839948 fix: resolve module import and naming conflicts
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:52:29 -03:00
Andrew Phillips
efb354ba38 refactor: remove redundant status module export from api mod
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:49:11 -03:00
Andrew Phillips
0846f2f0d9 fix: resolve module conflicts and import status module properly
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:49:04 -03:00
Andrew Phillips
29f4260b2a fix: remove duplicate status module export from server api mod.rs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:48:35 -03:00
Andrew Phillips
d4655203ba fix: resolve module conflicts and missing status import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:48:22 -03:00
Andrew Phillips
6ff67312aa fix: remove conflicting module declaration in server api mod.rs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:47:28 -03:00
Andrew Phillips
b6c19297a0 fix: resolve module conflicts and missing status import
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:47:23 -03:00
Andrew Phillips
af9edbbc3b fix: remove conflicting module file to resolve compilation error
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:47:05 -03:00
Andrew Phillips
71fac1e507 refactor: move generate_status_info to common module and update imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:46:23 -03:00
Andrew Phillips
0994ff0e48 fix: align /api/status endpoint with --status --output-format=json command output
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:40:33 -03:00
Andrew Phillips
eb5e979210 fix: resolve Args type import issues in server module
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:35:29 -03:00
Andrew Phillips
13f7e7e095 fix: remove unused imports and dead code
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:34:57 -03:00
Andrew Phillips
46fff9464f refactor: remove duplicate api route definitions 2025-08-12 15:33:45 -03:00
Andrew Phillips
4f84fdca34 refactor: rename item handler functions to match route paths consistently
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:33:37 -03:00
Andrew Phillips
8037105d86 refactor: rename item API endpoints for clarity and consistency 2025-08-12 15:33:36 -03:00
Andrew Phillips
5ea70fabbc fix: resolve module import conflicts and enum variant errors in API modules
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:05:19 -03:00
Andrew Phillips
11b5db815e fix: resolve module ambiguity by consolidating api routes into mod.rs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:04:39 -03:00
Andrew Phillips
e311f2b58f fix: resolve module naming conflict and router type issues
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:04:20 -03:00
Andrew Phillips
79cbffe9d4 fix: resolve module naming conflict and router type issues
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:03:56 -03:00
Andrew Phillips
20340be9ac fix: resolve module naming conflict and router method availability
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:02:22 -03:00
Andrew Phillips
25b70492bc refactor: restructure API routes with /api prefix and update content endpoint paths 2025-08-12 15:02:21 -03:00
Andrew Phillips
68c5514a44 refactor: use api::add_routes() and docs::add_routes() to register server routes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 15:01:40 -03:00
Andrew Phillips
6869b08a77 refactor: restructure server routing and add documentation routes 2025-08-12 15:01:38 -03:00
Andrew Phillips
4c82c55f58 refactor: move API routes to api.rs and remove unused imports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:50:14 -03:00
Andrew Phillips
afdecb6c51 fix: resolve module ambiguity and fix import paths in server docs
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:40:58 -03:00
Andrew Phillips
592e0586b4 fix: update module imports to reflect moved files in server mode
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:40:16 -03:00
Andrew Phillips
5150e2f478 chore: update dependencies and remove unused server modules 2025-08-12 14:38:59 -03:00
Andrew Phillips
f2c951ac73 feat: implement status endpoint with version, paths, and plugin info
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:35:52 -03:00
Andrew Phillips
e67d7ba98f fix: complete incomplete use statement and implement API router with all endpoints
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:32:57 -03:00
Andrew Phillips
703ae3b776 refactor: remove redundant server modules after api refactoring
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:32:44 -03:00
Andrew Phillips
077adc0cb0 refactor: remove redundant server API modules and update mod.rs exports
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:32:17 -03:00
Andrew Phillips
78c546e7e1 feat: add server API module 2025-08-12 14:32:08 -03:00
Andrew Phillips
fbdf2d84b7 refactor: reorganize REST API into modular endpoint files
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:25:50 -03:00
Andrew Phillips
04a8505e86 feat: add server API modules for status and item endpoints 2025-08-12 14:25:47 -03:00
Andrew Phillips
4d7bed7057 feat: implement API endpoints with /api prefix and add raw content/metadata routes
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:15:58 -03:00
Andrew Phillips
26bb2787d3 docs: add REST API endpoints documentation to DESIGN.md
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:09:17 -03:00
Andrew Phillips
c1fb3cb3ba docs: improve documentation for ProgramWriter and plugins module
Co-authored-by: aider (openai/andrew/openrouter/qwen/qwen3-coder) <aider@aider.chat>
2025-08-12 14:08:59 -03:00
154 changed files with 28465 additions and 5153 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
target/
.git/
*.db
keep.db
bin/

3
.gitignore vendored
View File

@@ -1,2 +1,5 @@
/target
.aider*
.crush
keep.db
bin/

65
AGENTS.md Normal file
View File

@@ -0,0 +1,65 @@
# Agent Configuration
**IMPORTANT:** `xxx | keep | zzz` must be as performant as possible in all situations.
## Build/Test Commands
**IMPORTANT**: Do not run the application, start the web server, or the trunk server.
**IMPORTANT:** Cargo commands cannot be run in parallel. Prefix all commands with `TERM=dumb`.
```bash
TERM=dumb cargo check # Fast compile check
TERM=dumb cargo build # Build project
TERM=dumb cargo test # Run all tests
TERM=dumb cargo test test_name # Run specific test by name substring
TERM=dumb cargo test -- --nocapture # Verbose test output
TERM=dumb cargo fmt --check # Check formatting
TERM=dumb cargo fmt # Apply formatting
TERM=dumb cargo clippy -- -D warnings # Lint (warnings are errors)
TERM=dumb cargo build --release # Release build
TERM=dumb cargo build --features server # With server feature
```
## Code Conventions
- `anyhow::Result` for error handling; `thiserror` for custom error types (`src/services/error.rs`)
- Plugin traits: `CompressionEngine`, `FilterPlugin`, `MetaPlugin`
- Dynamic trait objects use `clone_box()` for `Clone` on `Box<dyn Trait>`
- Plugin registration uses `ctor` constructors at module load time
- Filter plugins must implement `filter()`, `clone_box()`, and `options()`
- Meta plugins extend `BaseMetaPlugin` for boilerplate reduction
- Enum string representations: `#[strum(serialize_all = "snake_case")]`
- Lint rules: `deny(clippy::all)`, `deny(unsafe_code)` (except `libc::umask` in main.rs)
- Feature flags: `default = ["magic", "lz4", "gzip"]`; optional: `server`, `swagger`
## Testing
- Tests in `src/tests/` mirroring `src/` structure; shared helpers in `src/tests/common/test_helpers.rs`
- Key helpers: `create_temp_dir()`, `create_temp_db()`, `test_compression_engine()`
- Test naming: `test_<feature>_<scenario>`
## Streaming Constraint
**At no point should the whole file be in memory at once.** All I/O must use fixed-size buffers:
- `PIPESIZE` = 8192 bytes (`src/common/mod.rs:10`)
- Server POST body streams through `save_item_raw_streaming` via `MpscReader`
- Server GET content streams via streaming reader (not `read_to_end`)
- When `max_body_size` is exceeded, return `413` but keep the partial item (nonfatal by design)
- Filter/meta plugins use `PIPESIZE`-sized buffers
## HTML Rendering
- Use `html_escape` crate for all user-controlled data in HTML pages
- `esc()` for text content, `esc_attr()` for HTML attributes
- Security headers middleware: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`
## Changelog
The project uses [Keep a Changelog](https://keepachangelog.com/). The changelog lives at `CHANGELOG.md` in the project root.
- **Always update `CHANGELOG.md`** when making changes that affect users (new features, breaking changes, bug fixes, etc.)
- Add entries under the `[Unreleased]` section using these categories: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`
- Keep descriptions concise and user-focused — what changed from the user's perspective, not implementation details
- Commit changelog updates in the same commit as the feature/fix they document
- Before releasing a new version, move `[Unreleased]` entries to a versioned section (e.g., `[0.2.0] - YYYY-MM-DD`) and add a new empty `[Unreleased]` above it

107
CHANGELOG.md Normal file
View File

@@ -0,0 +1,107 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- New `filter_grep` feature to optionally include the grep filter plugin (regex-based line filtering). Disabling this feature removes the `regex` crate and its ~800 KiB dependency stack from the binary.
- New `meta_all_musl` feature for all MUSL-compatible meta plugins (excludes `meta_magic` which requires libmagic)
- New `filter_all_musl` feature for all MUSL-compatible filter plugins
- Database index on `items(ts)` column for faster ORDER BY sorting
- Server API `ItemInfo` now includes `file_size` — actual filesystem-reported size of the item data file
### Changed
- CLI args now feature-gated: `--server` and related options hidden when built without `server` feature; `--client-*` options hidden when built without `client` feature. Run `--help` only shows relevant options.
- `server` Cargo feature now includes TLS support by default (`axum-server`); `tls` feature removed
- Clap `conflicts_with_all` removed from all mode args — exclusivity now handled by implicit `group("mode")`
- Filter plugins check size before loading content into memory (prevents OOM on large inputs)
- Status page pre-allocates collections with known capacities (meta plugins, compression info)
- `#[inline]` on HTML escape helper functions (`esc`, `esc_attr`) for hot path performance
- Removed `once_cell` crate (replaced with `std::sync::LazyLock` from Rust 1.80)
- Removed `lazy_static` crate (replaced with `std::sync::LazyLock`)
### Breaking
- Plugin feature flags renamed with type prefix for consistency:
- `magic``meta_magic`
- `infer``meta_infer`
- `tree_magic_mini``meta_tree_magic_mini`
- `tokens``meta_tokens`
- `grep``filter_grep`
- `all-meta-plugins``meta_all`
- `all-filter-plugins``filter_all`
### Fixed
- CLI help text typo: "metatdata" → "metadata" in `--get` and `--info` descriptions
### Refactored
- Added module-level documentation to `services/` module
### Documentation
- README.md: Fixed compression table — zstd is native (not external), "none" renamed to "raw"
- DESIGN.md: Updated schema to reflect current `items` table columns and meta plugin inventory
## [0.1.0] - 2026-03-21
### Added
- Streaming tar-based export (`--export`) producing `.keep.tar` archives without loading entire files into memory
- Streaming tar-based import (`--import`) extracting `.keep.tar` archives with new IDs
- Server endpoints `GET /api/export` and `POST /api/import`
- ID-based filtering for `--list` (`keep -l 1 2 3` lists specific items by ID)
- Server API accepts optional `ids` query parameter on `GET /api/item/`
- `--ids-only` flag for `--list` mode for scripting
- `infer` and `tree_magic_mini` meta plugins for MIME type detection
- Native `zstd` compression plugin as default
- Configurable compression via `--compression` flag
- Export/import modes with format detection (JSON, YAML, binary)
- `XDG_CONFIG_HOME` support for default config file location
- `XDG_DATA_HOME` support for default storage location
- Tilde (`~`) expansion in config file paths
### Changed
- `CompressionType::None` renamed to `CompressionType::Raw` (with `"none"` as alias for backward compatibility)
- `items.size` column renamed to `items.uncompressed_size`
- Added `items.compressed_size` column tracking compressed file size on disk
- Added `items.closed` column tracking whether an item is fully written
- Default `list_format` in config now matches CLI default (7 vs 5 columns)
- All filter plugins share deduplicated option implementations
### Refactored
- Extracted `spawn_body_reader()` and `check_binary_content()` helpers for streaming uploads
- Extracted `yaml_value_to_string()` helper for meta plugins
- Extracted `item_path()` helper in `ItemService` to reduce path duplication
- Unified `get_item_meta_name`/`value` to take `&str` instead of `String`
- Shared `ItemInfo` struct between client and server
- Compression service now returns `Result` types instead of panicking via `.expect()`
- `ApiResponse::ok()` and `ApiResponse::empty()` constructors
- `meta_filter()` helper on `Settings` for consistent filtering
- Added `tag_names()` method on `ItemWithMeta`
- `filter_clone_box!` macro for filter plugin cloning
### Fixed
- Panic guards in diff, compression engine, and spawned threads
- Pre-existing borrow errors in export handler and `TryFrom` implementation
- TOCTOU race in `stream_raw_content_response`
- Swallowed write errors in meta plugins (digest, magic_file, exec)
- Truncated uploads (413) now properly store compressed data
- `term::stderr().unwrap()` panic in `item_service`
- `.unwrap()` panics in compression engine `Read`/`Write` impls
- Client API errors now propagate to user instead of being swallowed
- Import endpoint returns 413 on `max_body_size` instead of truncating
- `keep --list` uses `list_format` from config in all modes
- All tables respect `table_config` from settings
- `DisplayListItem` struct removed (was unused)
- `#[serde(alias = "size")]` on `ImportMeta` for backward compatibility

3562
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,53 +2,129 @@
name = "keep"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
description = "Keep and manage temporary files with automatic compression and metadata generation"
readme = "README.md"
license = "MIT"
repository = "https://gitea.gt0.ca/asp/keep"
keywords = ["cli", "files", "compression", "metadata"]
categories = ["command-line-utilities"]
[[test]]
name = "tests"
path = "src/tests.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.72"
clap = { version = "4.3.10", features = ["derive", "env"] }
directories = "5.0.1"
lazy_static = "1.4.0"
libc = "0.2.147"
log = "0.4.19"
rusqlite = { version = "0.29.0", features = ["bundled", "array", "chrono"] }
rusqlite_migration = "1.0.2"
stderrlog = "0.5.4"
strum_macros = "0.25"
strum = { version = "0.25", features = ["derive"] }
prettytable-rs = "0.10.0"
chrono = "0.4.26"
gethostname = "0.4.3"
humansize = "2.1.3"
enum-map = "2.6.1"
inventory = "0.3"
is-terminal = "0.4.9"
term = "0.7.0"
lz4_flex = "0.11.1"
flate2 = { version = "1.0.27", features = ["zlib-ng-compat"] }
regex = "1.9.5"
nix = "0.26.2"
sha2 = "0.10.0"
local-ip-address = "0.5.5"
dns-lookup = "2.0.2"
uzers = "0.11.3"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.142"
serde_yaml = "0.9.34"
tokio = { version = "1.0", features = ["full"] }
axum = "0.7"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
anyhow = "1.0"
axum = { version = "0.8", optional = true }
derive_more = { version = "2.0", features = ["full"] }
smart-default = "0.7"
thiserror = "2.0"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.6", features = ["derive", "env"] }
clap_complete = "4"
command-fds = "0.3"
config = "0.15"
ctor = "0.2"
directories = "6.0"
dns-lookup = "3.0"
enum-map = "2.7"
flate2 = { version = "1.0", features = ["zlib-ng-compat"], optional = true }
futures = "0.3"
gethostname = "1.0"
humansize = "2.1"
async-stream = "0.3"
hyper = { version = "1.0", features = ["full"] }
http-body-util = "0.1"
inventory = "0.3"
is-terminal = "0.4"
libc = "0.2"
local-ip-address = "0.6"
log = "0.4"
lz4_flex = { version = "0.12", optional = true }
zstd = { version = "0.13", optional = true }
magic = { version = "0.13", optional = true }
infer = { version = "0.19", optional = true }
tree_magic_mini = { version = "3.2", optional = true }
nix = { version = "0.30", features = ["fs", "process"] }
comfy-table = "7.2"
pwhash = "1.0"
regex = { version = "1.10", optional = true }
ringbuf = "0.4"
rusqlite = { version = "0.37", features = ["bundled", "array", "chrono"] }
rusqlite_migration = "2.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
sha2 = "0.10"
md5 = "0.7"
subtle = "2.6"
env_logger = "0.11"
strfmt = "0.2"
strum = { version = "0.27", features = ["derive"] }
term = "1.2"
tokio = { version = "1.0", features = ["full"] }
tokio-stream = "0.1"
tokio-util = "0.7"
tower = { version = "0.5", optional = true }
tower-http = { version = "0.6", features = ["cors", "fs", "trace"], optional = true }
utoipa = { version = "5.4", features = ["axum_extras"], optional = true }
utoipa-swagger-ui = { version = "9.0", features = ["axum"], optional = true }
uzers = "0.12"
which = "8.0"
xdg = "2.5"
strip-ansi-escapes = "0.2"
tar = "0.4"
pest = "2.8"
pest_derive = "2.8"
dirs = "6.0"
similar = { version = "2.7", default-features = false, features = ["text"] }
html-escape = "0.2"
ureq = { version = "3", features = ["json"], optional = true }
os_pipe = { version = "1", optional = true }
axum-server = { version = "0.8", features = ["tls-rustls"], optional = true }
jsonwebtoken = { version = "10", optional = true, features = ["aws_lc_rs"] }
tiktoken-rs = { version = "0.9", optional = true }
tempfile = "3.3"
[features]
# Default features include core compression engines plugins that support MUSL
default = [
"client",
"gzip",
"filter_grep",
"meta_infer",
"lz4",
"meta_tokens",
"meta_tree_magic_mini",
"zstd"
]
# Server feature (includes axum and TLS/HTTPS via axum-server; rustls already available via client/ureq)
server = ["dep:axum", "dep:tower", "dep:tower-http", "dep:utoipa", "dep:jsonwebtoken", "dep:axum-server"]
# Compression features
gzip = ["flate2"]
lz4 = ["lz4_flex"]
bzip2 = []
xz = []
zstd = ["dep:zstd"]
# Meta plugin features
meta_magic = ["dep:magic"]
meta_infer = ["dep:infer"]
meta_tree_magic_mini = ["dep:tree_magic_mini"]
meta_tokens = ["dep:tiktoken-rs"]
meta_all = ["meta_magic", "meta_infer", "meta_tree_magic_mini", "meta_tokens"]
meta_all_musl = ["meta_infer", "meta_tree_magic_mini", "meta_tokens"]
# Filter plugin features
filter_grep = ["dep:regex"]
filter_all = ["filter_grep"]
filter_all_musl = ["filter_grep"]
# Swagger UI feature
swagger = ["dep:utoipa-swagger-ui"]
# Client feature (HTTP client for remote server)
client = ["dep:ureq", "dep:os_pipe"]
[dev-dependencies]
tempfile = "3.3.0"
rand = "0.9"

199
DESIGN.md
View File

@@ -31,8 +31,9 @@
- `modes/info.rs` - Show detailed item information
- `modes/diff.rs` - Compare two items
- `modes/status.rs` - Show system status and capabilities
- `modes/server.rs` - REST HTTP server mode with OpenAPI documentation
- `modes/common.rs` - Shared utilities for all modes
- `modes/server.rs` - REST HTTP/HTTPS server mode with OpenAPI documentation
- `modes/client.rs` - Client mode for remote server (streaming save, local decompression)
- `modes/common.rs` - Shared utilities for all modes (OutputFormat, table creation, `print_serialized`, `build_path_table`, `ensure_default_tag`, `render_item_info_table`, `render_list_table_with_format`)
### Database Module
- `db.rs` - SQLite database operations
@@ -47,17 +48,195 @@
- `compression_engine/none.rs` - No compression implementation
- `compression_engine/program.rs` - External program wrapper
### Digest Functionality
- Digest functionality is now integrated into meta plugins
- SHA-256 and other digest algorithms are implemented as meta plugins
- External digest programs are supported through meta plugin program wrapper
### Meta Plugin Module
- `meta_plugin.rs` - Trait and type definitions
- `meta_plugin.rs` - Trait and type definitions, `SaveMetaFn` callback type
- `meta_plugin/program.rs` - External program wrapper
- `meta_plugin/digest.rs` - Internal digest implementations
- `meta_plugin/system.rs` - System information metadata plugins
### Plugins Module
**SaveMetaFn Architecture**: Meta plugins are decoupled from direct DB access via a `SaveMetaFn` callback (`Arc<Mutex<dyn FnMut(&str, &str) + Send>>`). The callback is injected at `MetaService` construction and propagated to all plugins via `BaseMetaPlugin`. This enables:
- **Local mode**: Callback collects metadata into a `Vec`, written to DB after plugins finish
- **Client mode**: Callback collects into a `HashMap`, sent to server after streaming completes
- **Server mode**: Callback collects into a `Vec`, written to DB after plugins finish (same as local)
### Common Modules
- `common/is_binary.rs` - Binary file detection utilities
- `common/status.rs` - Status information generation
- `common/mod.rs` - `PIPESIZE` constant (8192), `stream_copy()` streaming utility
### Client Module
- `client.rs` - HTTP client wrapper (ureq-based, supports streaming POST)
- `modes/client/save.rs` - 3-thread streaming save with local meta plugins (stdin → tee → compress → meta plugins → pipe → HTTP POST)
- `modes/client/get.rs` - Get with server-side raw fetch + local decompression
- `modes/client/list.rs` - List delegation to server
- `modes/client/info.rs` - Info delegation to server
- `modes/client/delete.rs` - Delete delegation to server
- `modes/client/diff.rs` - Diff delegation to server
- `modes/client/status.rs` - Status delegation to server
- `modes/client/update.rs` - Update delegation to server (sends plugin names/metadata/tags)
### Utility Modules
- `plugins.rs` - Shared plugin utilities
- Contains `ProgramWriter` for external process communication
- `args.rs` - CLI argument definitions
## Command Line Interface
### Modes
- Save mode: `keep [--save]` (default when no mode specified and no IDs provided)
- Get mode: `keep [--get] <ID|tag...>` (default when IDs provided)
- List mode: `keep [--list] [tag...]`
- Info mode: `keep [--info] <ID|tag...>`
- Delete mode: `keep [--delete] <ID...>`
- Update mode: `keep [--update] <ID> [tag...]`
- Diff mode: `keep [--diff] <ID1> <ID2>`
- Status mode: `keep [--status]`
- Server mode: `keep [--server] <address:port>`
### Item Options
- `--meta KEY[=VALUE]` - Set metadata for the item, remove if VALUE not provided
- `--digest <sha256|md5>` - Digest algorithm to use when saving items
- `--compression <lz4|gzip|bzip2|xz|zstd|none>` - Compression algorithm to use when saving items
- `--meta-plugins <plugin[,plugin...]>` - Meta plugins to use when saving items
### General Options
- `--dir <PATH>` - Specify the directory to use for storage
- `--list-format <FORMAT>` - A comma separated list of columns to display with --list
- `--human-readable` - Display file sizes with units
- `--verbose` - Increase message verbosity
- `--quiet` - Do not show any messages
- `--output-format <table|json|yaml>` - Output format for info, status, and list modes
- `--server-password <PASSWORD>` - Password for server authentication
- `--server-cert <PATH>` - TLS certificate file (PEM) for HTTPS server
- `--server-key <PATH>` - TLS private key file (PEM) for HTTPS server
- `--force` - Force output even when binary data would be sent to a TTY
### Client Options (requires `client` feature)
- `--client-url <URL>` - Remote keep server URL
- `--client-password <PASSWORD>` - Remote server password
## Data Storage
### Database Schema
- `items` table: id (primary key), ts (timestamp), uncompressed_size (optional), compressed_size (optional), closed (boolean), compression
- `tags` table: id (foreign key to items), name (tag name)
- `metas` table: id (foreign key to items), name (meta key), value (meta value)
- Indexes on tag names and meta names for faster queries
### File Storage
- Data directory contains compressed item files named by their item ID
- Database file stored in data directory
- File permissions set to be private to user (umask 077)
## REST API Endpoints
### Status Operations
- `GET /api/status` - Get system status information
- `GET /api/plugins/status` - Get plugin status information
### Item Operations
- `GET /api/item/` - Get a list of items as JSON. Optional params: `order=newest|oldest`, `start=0`, `count=100`, `tags=tag1,tag2`
- `POST /api/item/` - Add a new item (body: raw content, **streamed** through fixed-size 8192-byte buffers). Query params: `tags`, `metadata` (JSON), `compress=true|false`, `meta=true|false`
- `POST /api/item/<#>/meta` - Add metadata to an existing item (body: JSON object)
- `POST /api/item/<#>/update` - Re-run meta plugins on stored content. Query params: `plugins` (comma-separated), `metadata` (JSON), `tags` (comma-separated, idempotent)
- `DELETE /api/item/<#>` - Delete an item
- `GET /api/item/latest` - Return the latest item as JSON. Optional params: `tags=tag1,tag2`, `allow_binary=true|false`
- `GET /api/item/latest/meta` - Return the latest item metadata as JSON. Optional params: `tags=tag1,tag2`
- `GET /api/item/latest/content` - Return the raw content of the latest item (**streamed**). Optional params: `tags=tag1,tag2`, `decompress=true|false`
- `GET /api/item/<#>` - Return the item as JSON. Optional params: `allow_binary=true|false`
- `GET /api/item/<#>/meta` - Return the item metadata as JSON
- `GET /api/item/<#>/content` - Return the raw content of the item (**streamed**). Optional params: `decompress=true|false`
- `GET /api/diff` - Diff two items. Params: `id_a`, `id_b` (individual items capped at 10 MB)
### Server Configuration
- `max_body_size` - Maximum POST body size in bytes (default: unlimited). When exceeded, server returns `413 PAYLOAD_TOO_LARGE` while keeping the partial item already saved through the streaming pipeline. Set to `0` for unlimited.
### Server Modes
- **Plain HTTP** (default): `tokio::net::TcpListener` + `axum::serve()`
- **HTTPS** (with `tls` feature): `axum_server::bind_rustls()` with rustls when `--server-cert` and `--server-key` are provided
- Conditional selection at startup: cert+key present → HTTPS, otherwise → HTTP
### Client/Server Protocol
- Smart clients (keep CLI) set `compress=false` and `meta=false` on POST, handling compression and meta plugins locally
- Dumb clients (curl) use defaults (`compress=true`, `meta=true`), server handles everything
- Smart client update: sends `plugins` param to server, server runs plugins on stored content (avoids downloading compressed data)
- GET responses include `X-Keep-Compression` header when `decompress=false`
- Streaming save uses chunked transfer encoding for constant memory usage
- **Universal streaming**: All server paths (POST, GET, diff) use `PIPESIZE` (8192) byte buffers
- **413 partial item**: When `max_body_size` is exceeded, the server returns `413` but keeps the partial item already saved through the pipeline (nonfatal design — pipes continue normally)
### Authentication
- Bearer token authentication: `Authorization: Bearer <password>`
- Basic authentication: `Authorization: Basic base64(keep:<password>)`
- When no password is set, authentication is disabled
## Supported Compression Types
- LZ4 (internal implementation)
- GZip (internal implementation)
- BZip2 (external program)
- XZ (external program)
- ZStd (external program)
- None (no compression)
## Supported Meta Plugins
Meta plugins collect metadata during item save. Each plugin produces one or more key-value pairs:
- `magic_file` - File type detection using libmagic (when `magic` feature enabled)
- `infer` - MIME type detection using infer crate (when `infer` feature enabled)
- `tree_magic_mini` - MIME type detection using tree_magic_mini (when `tree_magic_mini` feature enabled)
- `tokens` - LLM token counting using tiktoken (when `tokens` feature enabled)
- `text` - Text analysis: line count, word count, char count, line average length
- `digest` - SHA-256 and MD5 checksums
- `hostname` - System hostname (full and short)
- `cwd` - Current working directory
- `user` - Current username and UID
- `shell` - Shell path from SHELL environment variable
- `shell_pid` - Shell process ID from PPID
- `keep_pid` - Keep process ID
- `env` - Arbitrary environment variables (via `KEEP_META_ENV_*` prefix)
- `exec` - Execute external commands for custom metadata
- `read_time` - Time taken to read content
- `read_rate` - Content read rate (bytes/second)
## Testing Strategy
- Unit tests for each module in `src/tests/`
- Integration tests for modes
- Database tests for CRUD operations
- Compression engine tests for each supported format
- Meta plugin tests for each plugin type
- Server tests for API endpoints and authentication
- Common utilities tests for helper functions
## Binary Data Handling
- Automatic binary detection using file signatures and heuristics
- Prevents binary data output to TTY unless --force is used
- Binary meta plugin analyzes content to determine if it's binary
- API endpoints respect binary flags to prevent accidental binary transmission
## Security Considerations
- File permissions are restricted to user only (umask 077)
- Input validation for item IDs to prevent path traversal
- Authentication for server mode with bearer or basic auth
- TLS/HTTPS support via rustls when certificate and key are provided
- Proper resource cleanup using RAII patterns
- Safe handling of external processes with proper stdin/stdout management
- **Streaming architecture**: All server I/O uses fixed-size 8192-byte buffers; no full file contents held in memory
- **XSS protection**: All user-controlled data in HTML pages is escaped via `html-escape`
- **Security headers**: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`
- **CORS**: Explicit allowed headers only (`Content-Type`, `Authorization`, `Accept`); no wildcard headers
- **Input limits**: Tags (256 chars), metadata keys (128 chars), metadata values (4096 chars), pagination (10,000 max)
- **Config file size**: 4 KB cap with `from_utf8_lossy` for safe UTF-8 handling
- **Error sanitization**: Internal errors never exposed in HTML responses
- **No `unsafe_code`**: Enforced via `#![deny(unsafe_code)]` (exceptions: `libc::umask` in main.rs, `unsafe impl Send` for `SendCookie` in magic_file.rs)
## Feature Flags
- `server` - HTTP REST API server (axum-based)
- `tls` - HTTPS/TLS support for server (axum-server + rustls)
- `client` - HTTP client for remote server (ureq-based, includes streaming save)
- `swagger` - OpenAPI/Swagger UI documentation
- `magic` - File type detection via libmagic
- `lz4` - LZ4 compression (internal)
- `gzip` - GZip compression (internal)
- `bzip2` - BZip2 compression (external)
- `xz` - XZ compression (external)
- `zstd` - ZStd compression (external)

67
Dockerfile Normal file
View File

@@ -0,0 +1,67 @@
# Build stage
FROM rust:1.88-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
cmake \
curl \
make \
gcc \
musl-tools \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-unknown-linux-musl
WORKDIR /app
# Copy manifests and fetch dependencies (cached layer)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && echo '' > src/lib.rs
RUN cargo fetch --target x86_64-unknown-linux-musl
# Copy real source and build static binary
# magic feature excluded (requires shared libmagic; fallback uses `file` command)
COPY src/ src/
RUN cargo build --release --target x86_64-unknown-linux-musl \
--no-default-features --features lz4,gzip,server,swagger,client,tls \
&& strip target/x86_64-unknown-linux-musl/release/keep
# Runtime stage - scratch since binary is fully static
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/keep /keep
COPY --from=builder /etc/ssl/certs/ /etc/ssl/certs/
EXPOSE 21080
# General options
# ENV KEEP_CONFIG=/config/config.yml
# Mount a volume for persistent storage: -v keep-data:/data
ENV KEEP_DIR=/data
ENV KEEP_LIST_FORMAT="id,time,size,tags,meta:hostname"
# Item options
# ENV KEEP_COMPRESSION=lz4
# ENV KEEP_META_PLUGINS=""
# ENV KEEP_FILTERS=""
# Server options
ENV KEEP_SERVER_ADDRESS=0.0.0.0
ENV KEEP_SERVER_PORT=21080
# ENV KEEP_SERVER_USERNAME="keep"
# ENV KEEP_SERVER_PASSWORD=""
# ENV KEEP_SERVER_PASSWORD_HASH=""
# ENV KEEP_SERVER_JWT_SECRET=""
# ENV KEEP_SERVER_JWT_SECRET_FILE=/config/jwt_secret
# TLS options
# ENV KEEP_SERVER_CERT=/certs/cert.pem
# ENV KEEP_SERVER_KEY=/certs/key.pem
# Client options
# ENV KEEP_CLIENT_URL=""
# ENV KEEP_CLIENT_USERNAME="keep"
# ENV KEEP_CLIENT_PASSWORD=""
# ENV KEEP_CLIENT_JWT=""
ENTRYPOINT ["/keep", "--server"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Andrew Phillips
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

81
PLAN.md
View File

@@ -1,81 +0,0 @@
# Code Quality Issues and Fixes
## Critical Issues
### 1. Memory Safety & Resource Leaks - DONE
**Files affected:** `src/modes/diff.rs`, `src/compression_engine/program.rs`
**Functions affected:** `mode_diff()`, `CompressionEngineProgram::open()`, `CompressionEngineProgram::create()`
**Problem example:** Raw file descriptors converted with `unsafe { std::fs::File::from_raw_fd(fd_write) }` without proper cleanup on errors
**Fix example:** Use RAII wrappers or ensure proper cleanup in Drop implementations and error paths
### 2. Error Handling Problems - DONE
**Files affected:** `src/modes/save.rs`, `src/modes/update.rs`, `src/db.rs`
**Functions affected:** `mode_save()`, `mode_update()`, `get_item()`, `insert_item()`
**Problem example:** `item.id.unwrap()` can panic if item.id is None
**Fix example:** Replace with `item.id.ok_or_else(|| anyhow!("Item missing ID"))?`
### 3. Concurrency Issues - DONE
**Files affected:** `src/modes/diff.rs`, `src/meta_plugin/digest.rs`
**Functions affected:** `mode_diff()`, meta plugin `update()` methods
**Problem example:** In `mode_diff()`, if writer threads panic, resources may not be cleaned up properly: `writer_thread_a.join()` only propagates panic but doesn't ensure file descriptors are closed
**Fix example:** Use RAII guards or ensure cleanup in panic handlers: `let _fd_guard = FileDescriptorGuard::new(fd_write);`
## Design Problems
### 4. Database Design Issues - DONE
**Files affected:** `src/db.rs`, `src/modes/save.rs`, `src/modes/update.rs`
**Functions affected:** `insert_item()`, `update_item()`, `store_meta()`, `set_item_tags()`
**Problem example:** Multiple database operations without transactions can leave partial state
**Fix example:** Wrap related operations in `conn.transaction()` blocks
### 5. Plugin Architecture Flaws
**Files affected:** `src/meta_plugin.rs`, `src/meta_plugin/digest.rs`, `src/meta_plugin/program.rs`
**Functions affected:** `MetaPlugin::create()`, `MetaPlugin::update()`, `MetaPlugin::finalize()`
**Problem example:**
- `create()` returns dummy writer that's never used, inconsistent with actual usage pattern
- `MetaPluginProgram::finalize()` spawns new process instead of reusing existing one
- No validation that meta plugins produce valid output formats
- Plugin errors are silently ignored in save operations
**Fix example:**
- Remove `create()` method and rely only on `update()`/`finalize()` pattern
- Reuse single process per plugin instance for better performance
- Add output validation and proper error propagation
### 6. Security Concerns - DONE
**Files affected:** `src/main.rs`, `src/modes/get.rs`, `src/modes/delete.rs`
**Functions affected:** `main()`, `mode_get()`, `mode_delete()`
**Problem example:** Item IDs used directly in file paths without validation: `item_path.push(item_id.to_string())`
**Fix example:** Validate item IDs are positive integers and sanitize file paths
## Performance Issues
### 7. Inefficient Operations
**Files affected:** `src/modes/save.rs`, `src/compression_engine.rs`
**Functions affected:** `mode_save()`, `CompressionEngine::size()`
**Problem example:** Fixed BUFSIZ buffer (typically 8KB) may not be optimal for all scenarios, especially large files or fast storage
**Fix example:** Use adaptive buffer sizing based on file size or storage characteristics, or allow configuration via environment variable
### 8. I/O Problems
**Files affected:** `src/meta_plugin/program.rs`, `src/compression_engine/program.rs`
**Functions affected:** `MetaPluginProgram::finalize()`, `CompressionEngineProgram::open()`, `CompressionEngineProgram::create()`
**Problem example:** Meta plugin processes can block indefinitely if they hang or produce large output without proper timeouts
**Fix example:** Add timeouts to process operations and non-blocking I/O for meta plugins: `process.wait_timeout(Duration::from_secs(30))`
## Code Quality Issues
### 9. Error Messages
**Files affected:** `src/modes/common.rs`, `src/main.rs`
**Functions affected:** `cmd_args_digest_type()`, `cmd_args_compression_type()`, `main()`
**Problem example:** `format!("Unknown digest type: {}", digest_name)` exposes internal terminology
**Fix example:** `format!("Invalid digest algorithm '{}'. Use 'sha256' or 'md5'", digest_name)`
### 10. Code Organization
**Files affected:** `src/modes/save.rs`, `src/modes/diff.rs`
**Functions affected:** `mode_save()`, `mode_diff()`
**Problem example:** Large functions doing multiple responsibilities
**Fix example:** Split into smaller functions:
- `src/modes/save.rs: mode_save()``setup_compression_and_plugins()`, `process_input_stream()`, `finalize_meta_plugins()`, `save_item_to_database()`
- `src/modes/diff.rs: mode_diff()``validate_diff_args()`, `setup_diff_pipes()`, `spawn_writer_threads()`, `execute_diff_command()`, `handle_diff_output()`
- `src/modes/diff.rs: write_item_to_pipe()``open_item_reader()`, `copy_item_data()`

957
README.md
View File

@@ -0,0 +1,957 @@
# Keep
A command-line utility for storing and retrieving temporary data with automatic compression, metadata extraction, and querying. Pipe any output into `keep` for organized storage — no more losing data in `/tmp` files with cryptic names.
```sh
# Instead of this:
curl -s https://api.example.com/data > /tmp/api-data.json
# Do this:
curl -s https://api.example.com/data | keep --save api-data
keep --get api-data
```
## Table of Contents
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Usage](#usage)
- [Save Mode](#save-mode)
- [Get Mode](#get-mode)
- [List Mode](#list-mode)
- [Info Mode](#info-mode)
- [Update Mode](#update-mode)
- [Delete Mode](#delete-mode)
- [Diff Mode](#diff-mode)
- [Status Mode](#status-mode)
- [Filters](#filters)
- [Compression](#compression)
- [Meta Plugins](#meta-plugins)
- [Configuration](#configuration)
- [Client/Server Mode](#clientserver-mode)
- [Server Mode](#server-mode)
- [Client Mode](#client-mode)
- [API Endpoints](#api-endpoints)
- [Shell Integration](#shell-integration)
- [Feature Flags](#feature-flags)
- [License](#license)
## Features
- **Store and retrieve** — Save content with tags, retrieve by ID or tag
- **Automatic compression** — LZ4, GZip, BZip2, XZ, ZStd support
- **Metadata plugins** — Auto-extract file type, digests, hostname, user info, and more
- **Filters** — Apply transformations (head, tail, grep, strip ANSI) on retrieval
- **Querying** — List, search, diff items with flexible formatting
- **Client/server architecture** — Optional HTTP server with streaming support
- **Modular design** — Extensible plugin system for compression, metadata, and filtering
## Installation
### From Source
Requires Rust and Cargo.
```sh
cargo build --release
```
### Install via Cargo
```sh
cargo install --path .
```
### Static Binary (Linux)
```sh
./build-static.bash
# Binary at bin/keep
```
### Environment Module
A TCL modulefile is provided at `modulefile`. To use it, copy or symlink the project directory into your modules path:
```sh
# Symlink into an existing module path (e.g., /usr/local/modules)
ln -s /path/to/keep /usr/local/modules/keep
# Load the module
module load keep
# Verify
keep --status
# Source the shell profile (optional, for shell integration)
source $KEEP_BASH_PROFILE # bash
source $KEEP_ZSH_PROFILE # zsh
source $KEEP_SH_PROFILE # sh/dash/ksh
source $KEEP_CSH_PROFILE # csh/tcsh
```
The modulefile prepends `keep/bin` to `PATH` and sets shell-specific profile variables:
| Variable | Profile | Shell |
|----------|---------|-------|
| `KEEP_BASH_PROFILE` | `profile.bash` | bash |
| `KEEP_ZSH_PROFILE` | `profile.zsh` | zsh |
| `KEEP_SH_PROFILE` | `profile.sh` | sh, dash, ksh93, pdksh, mksh |
| `KEEP_CSH_PROFILE` | `profile.csh` | csh, tcsh |
### Shell Completion
Tab completion is available for `bash`, `zsh`, `fish`, `elvish`, and `powershell`. Completions for `@` (save) and `@@` (get) are available for `bash` and `zsh` only.
**Bash** — add to `~/.bashrc`:
```sh
. <(keep --generate-completion bash)
```
**Zsh** — add to `~/.zshrc`:
```sh
. <(keep --generate-completion zsh)
```
**With `profile.bash` or `profile.zsh`**: Completions for `keep`, `@` (save), and `@@` (get) are loaded automatically when sourcing the profile.
### Build with Server/Client Features
```sh
# Server only
cargo build --release --features server
# Client only (for connecting to a remote keep server)
cargo build --release --features client
# Server + client + all optional features
cargo build --release --features server,client,swagger
```
## Quick Start
```sh
# Save content with a tag (--save is optional when piping)
echo "Hello, world!" | keep greeting
# Retrieve by ID (--get is optional for numeric IDs)
keep 1
# Retrieve by tag (--get is required for tags)
keep --get greeting
# List all stored items
keep --list
# Get item details
keep --info greeting
# Delete by ID
keep --delete 1
```
### Real-World Examples
```sh
# Save API response
curl -s https://api.github.com/repos/user/repo | keep --save repo-info
# Save test output with metadata
npm test 2>&1 | keep --save test-results --meta project=myapp --meta env=staging
# Chain commands: process and store
cat data.csv | sort | uniq | keep --save cleaned-data
# Diff two versions
keep --diff 1 5
# Get first 20 lines of an item
keep --get 1 --filters "head_lines(20)"
# List items from a specific project
keep --list --meta project=myapp
```
## Usage
### Save Mode
Save stdin content with tags and metadata. The `--save` flag is optional when piping content.
```sh
# Save (auto-assigned ID, no tag)
echo "data" | keep --save
# Save with a tag (--save is optional when piping)
echo "data" | keep --save my-tag
echo "data" | keep my-tag
# Save with multiple tags and metadata
cat report.pdf | keep --save report --meta project=alpha --meta env=prod
# Specify compression
echo "data" | keep --save my-tag --compression gzip
```
Tags and metadata make items easy to find later. Tags are simple identifiers; metadata is key-value pairs.
### Get Mode
Retrieve items by ID. This is the default mode when numeric IDs are provided.
```sh
# Get by ID (no --get needed for numeric IDs)
keep --get 1
keep 1
# Get by tag (requires --get flag)
keep --get my-tag
# Get with filters applied
keep --get 1 --filters "head_lines(10)"
# Get by metadata filter
keep --get --meta project=alpha
# Force binary output to TTY (override safety check)
keep --get 1 --force
```
### List Mode
List stored items with filtering and formatting.
```sh
# List all items
keep --list
# List by tag
keep --list my-tag
# Filter by metadata
keep --list --meta env=prod
# Custom column format
keep --list --list-format "id,time,size,tags"
# JSON output for scripting
keep --list --output-format json
# Human-readable file sizes
keep --list --human-readable
```
### Info Mode
Show detailed information about an item.
```sh
keep --info 1
keep --info my-tag
keep --info --meta key=value
```
### Update Mode
Update an item's tags, metadata, and re-run meta plugins.
```sh
# Replace tags
keep --update 1 new-tag
# Update metadata
keep --update 1 --meta key=newvalue
# Remove a metadata key
keep --update 1 --meta key
# Re-run meta plugins on stored content
keep --update 1 --meta-plugin digest --meta-plugin text
```
### Delete Mode
Delete items by ID.
```sh
keep --delete 1
keep --delete 1 2 3
```
### Diff Mode
Show differences between two items.
```sh
keep --diff 1 2
```
### Status Mode
Show system status and supported features.
```sh
keep --status
keep --status-plugins
keep --status --verbose
```
## Filters
Apply transformations to item content during retrieval. Filters are chained with `|`.
```sh
# First 10 lines
keep --get 1 --filters "head_lines(10)"
# Skip first 5 lines, then grep for errors
keep --get 1 --filters "skip_lines(5)|grep(pattern=error)"
# Strip ANSI escape codes
keep --get 1 --filters "strip_ansi"
# Last 100 bytes
keep --get 1 --filters "tail_bytes(100)"
# Complex chain
keep --get 1 --filters "skip_lines(10)|grep(pattern=TODO)|head_lines(5)"
```
### Available Filters
| Filter | Description | Parameters |
|--------|-------------|------------|
| `head_bytes(n)` | First n bytes | `count` |
| `head_lines(n)` | First n lines | `count` |
| `tail_bytes(n)` | Last n bytes | `count` |
| `tail_lines(n)` | Last n lines | `count` |
| `skip_bytes(n)` | Skip first n bytes | `count` |
| `skip_lines(n)` | Skip first n lines | `count` |
| `grep(pattern)` | Filter matching lines | `pattern` (regex) |
| `strip_ansi` | Remove ANSI escape codes | none |
Set `KEEP_FILTERS` to apply a default filter chain to all retrievals.
## Compression
Items are compressed automatically on save. Default: LZ4.
| Algorithm | Type | Speed | Ratio |
|-----------|------|-------|-------|
| `lz4` | Internal | Fastest | Lower |
| `gzip` | Internal | Fast | Good |
| `bzip2` | External | Slow | Better |
| `xz` | External | Slowest | Best |
| `zstd` | Internal | Fast | Good |
| `raw` | Internal | N/A | N/A |
```sh
# Specify compression per item
echo "data" | keep --save my-tag --compression zstd
# Set default via environment
export KEEP_COMPRESSION=gzip
```
External compression programs (`bzip2`, `xz`, `zstd`) must be installed on the system.
## Meta Plugins
Metadata is automatically extracted when saving items.
| Plugin | Key | Description |
|--------|-----|-------------|
| `env` | `*` | Capture `KEEP_META_*` environment variables |
| `magic_file` | `file_type` | File type detection (requires `magic` feature) |
| `text` | `text_line_count`, `text_word_count` | Line and word counts |
| `user` | `user_uid`, `user_name`, `user_gid`, `user_group` | Current user info |
| `shell` | `shell` | Current shell path |
| `shell_pid` | `shell_pid` | Shell process ID |
| `keep_pid` | `keep_pid` | Keep process ID |
| `digest` | `digest_sha256`, `digest_md5` | Content digests |
| `read_time` | `read_time` | Time to read content |
| `read_rate` | `read_rate` | Data read rate |
| `hostname` | `hostname`, `hostname_short` | System hostname |
| `exec` | Custom | Run external commands for metadata |
| `cwd` | `cwd` | Current working directory |
```sh
# Use specific plugins (repeatable)
echo "data" | keep --save tag --meta-plugin digest --meta-plugin text --meta-plugin user
# Pass options to a plugin via JSON
echo "data" | keep --save tag --meta-plugin 'tokens:{"options":{"min_length":"2"}}'
# Capture custom metadata via environment
KEEP_META_project=alpha echo "data" | keep --save tag
# Combine environment and CLI metadata
KEEP_META_build=1234 echo "data" | keep --save tag --meta env=staging
```
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `KEEP_DIR` | Storage directory | `~/.keep` |
| `KEEP_CONFIG` | Config file path | `~/.config/keep/config.yml` |
| `KEEP_COMPRESSION` | Compression algorithm | `lz4` |
| `KEEP_META_PLUGINS` | Meta plugins to use (JSON format: `name[:{json}]`, comma-separated) | `env` |
| `KEEP_FILTERS` | Default filter chain | none |
| `KEEP_LIST_FORMAT` | List column format | built-in defaults |
| `KEEP_SERVER_ADDRESS` | Server bind address | `127.0.0.1` |
| `KEEP_SERVER_PORT` | Server port | `21080` |
| `KEEP_SERVER_USERNAME` | Server Basic auth username | `keep` |
| `KEEP_SERVER_PASSWORD` | Server password | none |
| `KEEP_SERVER_PASSWORD_HASH` | Server password hash | none |
| `KEEP_SERVER_JWT_SECRET` | JWT secret for token auth | none |
| `KEEP_SERVER_JWT_SECRET_FILE` | Path to JWT secret file | none |
| `KEEP_SERVER_MAX_BODY_SIZE` | Maximum POST body size in bytes (0=unlimited) | unlimited |
| `KEEP_SERVER_CERT` | TLS certificate file path (PEM) | none |
| `KEEP_SERVER_KEY` | TLS private key file path (PEM) | none |
| `KEEP_CLIENT_URL` | Remote keep server URL | none |
| `KEEP_CLIENT_USERNAME` | Remote server username | `keep` |
| `KEEP_CLIENT_PASSWORD` | Remote server password | none |
| `KEEP_CLIENT_JWT` | JWT token for remote server | none |
Any config setting can be overridden with `KEEP__<SETTING>` environment variables (double underscore separator).
### Configuration File
Default location: `~/.config/keep/config.yml`
Generate a default configuration:
```sh
keep --generate-config > ~/.config/keep/config.yml
```
```yaml
# Storage directory
dir: ~/.keep
# List view columns
list_format:
- name: id
label: "Item"
align: right
- name: time
label: "Time"
align: right
- name: size
label: "Size"
align: right
- name: tags
label: "Tags"
align: left
# Table styling
table_config:
style: utf8_full
content_arrangement: dynamic
# Default compression
compression_plugin:
name: gzip
# Default meta plugins
meta_plugins:
- name: env
- name: digest
options:
algorithm: sha256
# Server settings
server:
address: "127.0.0.1"
port: 21080
username: "keep"
password: "secret"
# Maximum POST body size in bytes (0 = unlimited)
# max_body_size: 52428800 # 50 MB
# JWT authentication (takes priority over password)
# jwt_secret: "my-secret-key"
# jwt_secret_file: /path/to/jwt_secret
# TLS (requires tls feature)
# cert_file: /path/to/cert.pem
# key_file: /path/to/key.pem
# Client settings
client:
url: "http://localhost:21080"
username: "keep"
password: "secret"
# Or use JWT token
# jwt: "eyJhbGciOiJIUzI1NiIs..."
human_readable: true
quiet: false
force: false
```
## Client/Server Mode
Keep supports a client/server architecture where one machine runs a keep server and other machines connect as clients. This is useful for:
- Centralizing stored data across multiple machines
- Sharing items between team members
- Offloading storage to a dedicated server
- Piping data from long-running processes without local storage
### Server Mode
Start an HTTP REST API server:
```sh
# Default: 127.0.0.1:21080
keep --server
# Custom address and port
keep --server --server-address 0.0.0.0 --server-port 8080
# With password authentication
keep --server --server-password mypassword
# With custom username
keep --server --server-username admin --server-password mypassword
# With JWT authentication
keep --server --server-jwt-secret my-secret-key
```
#### JWT Authentication
JWT (JSON Web Token) authentication provides permission-based access control. When a JWT secret is configured, the server validates tokens and checks permission claims for each request.
**Configuration:**
```sh
# Via CLI flag
keep --server --server-jwt-secret my-secret-key
# Via environment variable
export KEEP_SERVER_JWT_SECRET=my-secret-key
keep --server
# Via config file (config.yml)
server:
jwt_secret: "my-secret-key"
# Via secret file (for Docker/secrets management)
keep --server --server-jwt-secret-file /path/to/secret
```
**Token format:**
JWTs must use HS256 algorithm with the following claims:
| Claim | Type | Required | Description |
|-------|------|----------|-------------|
| `sub` | string | Yes | Subject (client identifier) |
| `exp` | number | Yes | Expiration time (Unix timestamp) |
| `read` | boolean | No | Permission for GET requests (default: false) |
| `write` | boolean | No | Permission for POST/PUT requests (default: false) |
| `delete` | boolean | No | Permission for DELETE requests (default: false) |
**Permission mapping:**
| HTTP Method | Required Permission |
|-------------|-------------------|
| `GET` | `read` |
| `POST`, `PUT`, `PATCH` | `write` |
| `DELETE` | `delete` |
**Example token payload:**
```json
{
"sub": "ci-pipeline",
"exp": 1735689600,
"read": true,
"write": true,
"delete": false
}
```
**Generating tokens:**
The server does not generate tokens — use any JWT library or tool:
```sh
# Using jwt-cli (https://github.com/mike-engel/jwt-cli)
jwt encode --secret my-secret-key \
--exp=$(date -d '+24 hours' +%s) \
'{"sub":"my-client","read":true,"write":true,"delete":false}'
# Using Python
python3 -c "
import jwt, time
token = jwt.encode({
'sub': 'my-client',
'exp': int(time.time()) + 86400,
'read': True, 'write': True, 'delete': False
}, 'my-secret-key', algorithm='HS256')
print(token)
"
```
**Using tokens:**
```sh
# With curl
curl -H "Authorization: Bearer <jwt-token>" http://localhost:21080/api/item/
# The keep client uses --client-jwt for JWT tokens
keep --client-url http://server:21080 --client-jwt <jwt-token> --save my-tag
```
**Response codes:**
| Code | Meaning |
|------|---------|
| `200` | Authorized |
| `401` | Missing, invalid, or expired token |
| `403` | Valid token but insufficient permissions |
**Notes:**
- When `jwt_secret` is set, password authentication is disabled — all requests must present a valid JWT Bearer token
- JWT and password authentication are mutually exclusive — when both `jwt_secret` and `password` are configured, only JWT is used
- Permission fields default to `false` if omitted — tokens must explicitly grant permissions
- JWT authentication requires the `server` feature (jsonwebtoken is included automatically)
#### HTTPS / TLS
Build with the `tls` feature to enable HTTPS:
```sh
cargo build --release --features server,tls
```
Provide a TLS certificate and private key (both PEM format):
```sh
# Via CLI flags
keep --server \
--server-cert /path/to/cert.pem \
--server-key /path/to/key.pem
# Via environment variables
export KEEP_SERVER_CERT=/path/to/cert.pem
export KEEP_SERVER_KEY=/path/to/key.pem
keep --server
# Via config file (config.yml)
server:
cert_file: /path/to/cert.pem
key_file: /path/to/key.pem
```
When cert and key are provided, the server listens with HTTPS. Without them, it falls back to plain HTTP. The port is controlled by `--server-port` (default: 21080).
**Self-signed certificates** (for development):
```sh
# Generate a self-signed cert
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
-days 365 -nodes -subj "/CN=localhost"
# Start server with self-signed cert
keep --server --server-cert cert.pem --server-key key.pem
# Connect client with HTTPS
keep --client-url https://localhost:21080 --save my-tag
```
The server accepts data from both dumb clients (raw HTTP/curl) and smart clients (the keep CLI).
#### Server Streaming
The server streams all data through fixed-size buffers (8192 bytes). At no point is the entire file content held in memory.
- **POST**: Body streams through the compression and storage pipeline in chunks. When `max_body_size` is exceeded, the server returns `413 PAYLOAD_TOO_LARGE` while keeping the partial item already saved through the pipeline.
- **GET**: Content streams from disk through decompression to the client using the same fixed-size buffers.
- **Diff**: Individual items are capped at 10 MB for the diff endpoint to prevent unbounded memory use.
##### Max Body Size
Control the maximum accepted body size with:
```sh
# Via CLI flag (bytes)
keep --server --server-max-body-size 52428800
# Via environment variable
export KEEP_SERVER__MAX_BODY_SIZE=52428800
keep --server
# Via config file (config.yml)
server:
max_body_size: 52428800 # 50 MB
```
When set to `0` or omitted, no limit is enforced.
#### Server Query Parameters
The server supports query parameters that control processing:
| Parameter | Default | Description |
|-----------|---------|-------------|
| `tags` | none | Comma-separated tags |
| `metadata` | none | JSON-encoded metadata |
| `compress` | `true` | `false` = client already compressed, store as-is |
| `meta` | `true` | `false` = client handles metadata, skip server-side plugins |
| `decompress` | `true` | `false` = return raw compressed bytes on GET |
The `POST /api/item/{id}/update` endpoint accepts additional parameters:
| Parameter | Default | Description |
|-----------|---------|-------------|
| `plugins` | none | Comma-separated plugin names to re-run on stored content |
| `metadata` | none | JSON-encoded metadata overrides to apply |
| `tags` | none | Comma-separated tags to add (idempotent) |
When using a smart client, these are set automatically. For curl, the server handles everything by default.
#### Example: Curl as a Dumb Client
```sh
# Save (server handles compression and metadata)
curl -X POST -d "my data" http://localhost:21080/api/item/?tags=my-tag
# Retrieve (server decompresses)
curl http://localhost:21080/api/item/1/content
# Save compressed (client handles compression, server skips)
gzip -c data.txt | curl -X POST -d @- "http://localhost:21080/api/item/?compress=false&tags=my-tag"
```
### Client Mode
The keep CLI can connect to a remote server as a smart client. Build with the `client` feature:
```sh
cargo build --release --features client
```
```sh
# Set server URL via flag or environment
keep --client-url http://server:21080 --save my-tag
export KEEP_CLIENT_URL=http://server:21080
# With password authentication
keep --client-url http://server:21080 --client-password mypassword --save my-tag
export KEEP_CLIENT_PASSWORD=mypassword
# With custom username
keep --client-url http://server:21080 --client-username admin --client-password mypassword --save my-tag
# With JWT authentication
keep --client-url http://server:21080 --client-jwt <jwt-token> --save my-tag
export KEEP_CLIENT_JWT=<jwt-token>
```
#### How Client Mode Works
Client mode uses **local plugins** and **remote storage**:
1. **Save**: Local compression and meta plugins run on the client; compressed data streams to the server. Smart clients set `meta=false` so the server skips its own plugins.
2. **Get**: Server sends raw compressed data; client decompresses locally and applies filters
3. **Update**: Meta plugins run on the server to avoid downloading compressed data for re-processing
4. **Other operations** (list, info, delete, diff): Delegated directly to the server
This means client behavior is consistent with local mode — the same compression settings and filters apply.
#### Streaming Architecture
Client save uses a 3-thread streaming pipeline for constant memory usage regardless of data size:
```
┌───────────────────┐ OS pipe ┌────────────────┐
│ Reader thread ├──────────────────┤ Streamer thread│
│ │ (compressed │ │
│ stdin → tee │ bytes) │ pipe → POST │
│ → hash │ │ (chunked) │
│ → compress │ │ │
│ → meta plugins │ │ │
└───────────────────┘ └────────────────┘
│ │
▼ ▼
stdout + Server stores blob
computed metadata
```
- **Reader thread**: Reads stdin, tees output to stdout, computes SHA-256 via digest plugin, compresses data, runs meta plugins (hostname, text, etc.), writes to OS pipe
- **Streamer thread**: Reads compressed bytes from pipe, streams to server via chunked HTTP POST
- **Main thread**: After streaming completes, sends plugin-collected metadata to server
Memory usage is O(PIPESIZE) — typically 8 KB — regardless of how much data is being stored.
#### Example: Remote Pipeline
```sh
# On a build server, pipe logs to a central keep server
make build 2>&1 | keep --client-url http://logserver:21080 \
--save build-logs \
--meta project=myapp \
--meta branch=$(git branch --show-current)
# Retrieve from any machine
keep --client-url http://logserver:21080 --get build-logs
# List recent builds from a specific project
keep --client-url http://logserver:21080 --list --meta project=myapp
```
### API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/status` | System status |
| `GET` | `/api/plugins/status` | Plugin status |
| `GET` | `/api/item/` | List items (`tags`, `order`, `start`, `count` params) |
| `POST` | `/api/item/` | Create item (body: raw content, params: `tags`, `metadata`, `compress`, `meta`) |
| `GET` | `/api/item/latest/content` | Latest item content |
| `GET` | `/api/item/latest/meta` | Latest item metadata |
| `GET` | `/api/item/{id}` | Item info by ID |
| `GET` | `/api/item/{id}/content` | Item content by ID |
| `GET` | `/api/item/{id}/meta` | Item metadata by ID |
| `GET` | `/api/item/{id}/info` | Item info by ID |
| `POST` | `/api/item/{id}/meta` | Add metadata to existing item (body: JSON object) |
| `POST` | `/api/item/{id}/update` | Re-run meta plugins on stored content (params: `plugins`, `metadata`, `tags`) |
| `DELETE` | `/api/item/{id}` | Delete item by ID |
| `GET` | `/api/diff` | Diff two items (`id_a`, `id_b` params) |
#### Authentication
The server supports three authentication modes:
**1. Password (HTTP Basic auth):**
```sh
# Default username is "keep"
curl -u keep:mypassword http://localhost:21080/api/status
# Custom username
curl -u admin:mypassword http://localhost:21080/api/status
```
**2. JWT (permission-based):**
```sh
# Valid JWT with read permission allows GET requests
curl -H "Authorization: Bearer <jwt-token>" http://localhost:21080/api/item/
```
See [JWT Authentication](#jwt-authentication) for token format and configuration.
**3. No authentication:**
When neither password nor JWT secret is configured, authentication is disabled.
#### Swagger UI
Build with the `swagger` feature to enable OpenAPI documentation:
```sh
cargo build --features server,swagger
```
Swagger UI available at `/swagger`, OpenAPI spec at `/openapi.json`.
#### Security
The server applies the following security measures:
- **Input validation**: Item IDs are validated as positive integers; tags and metadata have length limits (256 and 128 characters respectively).
- **XSS protection**: All user-controlled data rendered into HTML pages is escaped.
- **Security headers**: Responses include `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: strict-origin-when-cross-origin`.
- **CORS**: Explicit allowed headers (`Content-Type`, `Authorization`, `Accept`); no wildcard headers.
- **Path traversal**: Item IDs are validated to prevent directory traversal attacks.
- **Internal errors**: Internal error details are never exposed in HTML responses — only generic messages are shown.
## Shell Integration
Profile scripts are provided for several shells. Source the appropriate one to enable shell integration:
| Profile | Shells | Features |
|---------|--------|----------|
| `profile.bash` | bash | Preexec hook, wrapper function, `@`/`@@` aliases, tab completions |
| `profile.zsh` | zsh | Preexec hook, wrapper function, `@`/`@@` aliases, tab completions |
| `profile.sh` | sh, dash, ksh93, pdksh, mksh | Wrapper function, `@`/`@@` aliases |
| `profile.csh` | csh, tcsh | Alias-based `keep` wrapper, `@`/`@@` aliases |
```sh
# bash
source /path/to/keep/profile.bash
# zsh
source /path/to/keep/profile.zsh
# sh, dash, ksh
source /path/to/keep/profile.sh
# csh/tcsh
source /path/to/keep/profile.csh
```
All profiles provide:
- **`@` alias** — Shorthand for `keep --save`
- **`@@` alias** — Shorthand for `keep --get`
Bash and zsh profiles additionally provide:
- **`keep` function** — Captures the current command in metadata automatically
- **Tab completion** — For `keep`, `@`, and `@@`
```sh
# Save with automatic command capture (bash/zsh)
curl -s api.example.com | @ api-response
# Quick retrieve
@@ api-response
```
## Feature Flags
| Feature | Default | Description |
|---------|---------|-------------|
| `magic` | Yes | File type detection via libmagic |
| `lz4` | Yes | LZ4 compression (internal) |
| `gzip` | Yes | GZip compression (internal) |
| `server` | No | HTTP REST API server |
| `tls` | No | HTTPS/TLS server support (requires `server`) |
| `client` | No | HTTP client for remote server |
| `swagger` | No | Swagger UI for API docs |
| `bzip2` | No | BZip2 compression (external program) |
| `xz` | No | XZ compression (external program) |
| `zstd` | No | ZStd compression (external program) |
```sh
# Server with Swagger UI
cargo build --features server,swagger
# Server with HTTPS
cargo build --features server,tls
# Client only
cargo build --features client
# Everything
cargo build --features server,tls,client,swagger,magic
```
## License
MIT License - see [LICENSE](LICENSE) for details.
## Contact
Andrew Phillips - andrew@gt0.ca

View File

@@ -1,141 +0,0 @@
#+TITLE: Keep
#+AUTHOR: Andrew Phillips
* Introduction
Keep is a command-line utility designed to manage temporary files created on the command line. Instead of redirecting output to a temporary file (e.g., =command > ~/whatever.tmp=), you can use =keep= to handle the temporary files for you (e.g., =command | keep=).
* Installation
To install Keep, you need to have Rust and Cargo installed on your system. You can then build and install Keep using the following commands:
#+BEGIN_SRC sh
cargo build --release
cargo install --path .
#+END_SRC
* Usage
Keep provides several subcommands to manage temporary files. Below are some examples of how to use Keep.
** Saving an Item
To save an item with tags and metadata, you can use the =--save= option:
#+BEGIN_SRC sh
echo "Hello, world!" | keep --save example --meta key=value
#+END_SRC
** Getting an Item
To retrieve an item by its ID or by matching tags and metadata, you can use the =--get= option:
#+BEGIN_SRC sh
keep --get 1
keep --get example
keep --get --meta key=value
keep 1
keep example
#+END_SRC
** Listing Items
To list all items or filter them by tags and metadata, you can use the =--list= option:
#+BEGIN_SRC sh
keep --list
keep --list example
keep --list --meta key=value
#+END_SRC
** Updating an Item
To update an item's tags and metadata, you can use the =--update= option:
#+BEGIN_SRC sh
keep --update 1 newtag --meta key=newvalue
#+END_SRC
** Deleting an Item
To delete an item by its ID or by matching tags, you can use the =--delete= option:
#+BEGIN_SRC sh
keep --delete 1
keep --delete example
#+END_SRC
** Showing Status
To show the status of directories and supported compression algorithms, you can use the =--status= option:
#+BEGIN_SRC sh
keep --status
#+END_SRC
** Diffing Items
To show a diff between two items by ID, you can use the =--diff= option:
#+BEGIN_SRC sh
keep --diff 1 2
#+END_SRC
** Getting Information About an Item
To get detailed information about an item by its ID or by matching tags and metadata, you can use the =--info= option:
#+BEGIN_SRC sh
keep --info 1
keep --info example
keep --info --meta key=value
#+END_SRC
* Configuration
Keep can be configured using environment variables and command-line options. The following environment variables are supported:
- =KEEP_DIR=: Specify the directory to use for storage.
- =KEEP_LIST_FORMAT=: A comma-separated list of columns to display with =--list=.
- =KEEP_DIGEST=: Digest algorithm to use when saving items.
- =KEEP_COMPRESSION=: Compression algorithm to use when saving items.
* Examples
Here are some examples of how to use Keep with different options:
** Saving an Item with Compression and Digest
#+BEGIN_SRC sh
echo "Hello, world!" | keep --save example --meta key=value --compression gzip --digest sha256
#+END_SRC
** Getting an Item with Human-Readable Sizes
#+BEGIN_SRC sh
keep --get 1 --human-readable
#+END_SRC
** Listing Items with Custom Format
#+BEGIN_SRC sh
keep --list --list-format "id,time,size,tags,meta:hostname"
#+END_SRC
** Updating an Item with New Tags and Metadata
#+BEGIN_SRC sh
keep --update 1 newtag --meta key=newvalue
#+END_SRC
** Deleting an Item by Tag
#+BEGIN_SRC sh
keep --delete example
#+END_SRC
** Showing Status with Verbose Output
#+BEGIN_SRC sh
keep --status --verbose
#+END_SRC
** Diffing Items with IDs
#+BEGIN_SRC sh
keep --diff 1 2
#+END_SRC
** Getting Information About an Item with Metadata
#+BEGIN_SRC sh
keep --info 1
#+END_SRC
* License
Keep is licensed under the MIT License. See the LICENSE file for more details.
* Contributing
Contributions are welcome! Please open an issue or submit a pull request on the GitHub repository.
* Contact
For more information, please contact Andrew Phillips at andrew@gt0.ca.

View File

@@ -2,7 +2,6 @@
set -ex
export RUSTFLAGS='-C target-feature=+crt-static'
cargo build --release --target x86_64-unknown-linux-gnu
cargo build --release --target x86_64-unknown-linux-musl
mkdir -p bin
cp target/x86_64-unknown-linux-gnu/release/keep ./bin/
cp target/x86_64-unknown-linux-musl/release/keep ./bin/

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
keep:
build: .
ports:
- "21080:21080"
volumes:
- keep-data:/data
- keep-config:/config
environment:
- KEEP_SERVER_ADDRESS=0.0.0.0
- KEEP_SERVER_PORT=21080
# - KEEP_SERVER_USERNAME=keep
# - KEEP_SERVER_PASSWORD=changeme
# - KEEP_SERVER_PASSWORD_HASH=
# - KEEP_SERVER_JWT_SECRET=
# - KEEP_SERVER_JWT_SECRET_FILE=/config/jwt_secret
# - KEEP_COMPRESSION=lz4
# - KEEP_META_PLUGINS=
# - KEEP_FILTERS=
- KEEP_CONFIG=/config/config.yml
# - KEEP_SERVER_CERT=/certs/cert.pem
# - KEEP_SERVER_KEY=/certs/key.pem
# - KEEP_CLIENT_USERNAME=keep
# - KEEP_CLIENT_JWT=""
restart: unless-stopped
# For TLS, mount certificate files:
# volumes:
# - ./certs:/certs:ro
volumes:
keep-data:
keep-config:

View File

@@ -14,3 +14,7 @@ set mydir [ file normalize $mydir_base ]
module-whatis Keep
prepend-path PATH $mydir/bin
setenv KEEP_BASH_PROFILE ${mydir}/profile.bash
setenv KEEP_ZSH_PROFILE ${mydir}/profile.zsh
setenv KEEP_SH_PROFILE ${mydir}/profile.sh
setenv KEEP_CSH_PROFILE ${mydir}/profile.csh

View File

@@ -2,28 +2,14 @@
function __keep_preexec {
KEEP_META_command="$1"
KEEP_META_directory=${KEEP_META_directory:-${PWD}}
KEEP_META_hostname=${KEEP_META_hostname:-${HOSTNAME:-$(hostname -f)}}
KEEP_META_tty=${KEEP_META_tty:-$(tty)}
}
function __keep_preexec_init {
local found=false
local f
for f in "${preexec_functions[@]}"; do
if [[ $f = __keep_preexec ]]; then
found=true
break
fi
[[ $f = __keep_preexec ]] && return
done
if [[ $found = false ]]; then
preexec_functions+=(__keep_preexec)
fi
if [[ -z $KEEP_LIST_FORMAT ]]; then
export KEEP_LIST_FORMAT="id,time,size,tags,meta:hostname,meta:command"
fi
preexec_functions+=(__keep_preexec)
}
function keep {
@@ -32,8 +18,6 @@ function keep {
export KEEP_META_command
fi
export KEEP_META_directory
export KEEP_META_hostname
export KEEP_META_tty
exec keep "$@"
@@ -48,4 +32,20 @@ function @@ {
keep --get "$@"
}
# Shell completions
. <(command keep --generate-completion bash)
___keep_complete() {
local mode="$1"
COMP_WORDS=(keep "$mode" "${COMP_WORDS[@]:1}")
COMP_CWORD=$((COMP_CWORD + 1))
_keep
}
___keep_save_completion() { ___keep_complete --save; }
___keep_get_completion() { ___keep_complete --get; }
complete -F ___keep_save_completion @
complete -F ___keep_get_completion @@
__keep_preexec_init

11
profile.csh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/csh
# Profile for csh and tcsh.
# Preexec hooks are not available; KEEP_META_command is not set.
if ( ! $?KEEP_META_tty ) then
setenv KEEP_META_tty `tty`
endif
alias keep 'env KEEP_META_tty=${KEEP_META_tty} command keep \!*'
alias @ 'keep --save \!*'
alias @@ 'keep --get \!*'

13
profile.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# POSIX-compatible profile for sh, dash, ksh93, pdksh, mksh, and other POSIX shells.
# Preexec hooks are not available in these shells; KEEP_META_command is not set.
KEEP_META_tty=${KEEP_META_tty:-$(tty)}
keep() {
export KEEP_META_tty
command keep "$@"
}
alias @='keep --save'
alias @@='keep --get'

38
profile.zsh Normal file
View File

@@ -0,0 +1,38 @@
#!/bin/zsh
autoload -U add-zsh-hook
__keep_preexec() {
KEEP_META_command="$1"
KEEP_META_tty=${KEEP_META_tty:-$(tty)}
}
add-zsh-hook preexec __keep_preexec
keep() {
if [[ $ZSH_SUBSHELL -le 2 ]]; then
export KEEP_META_command
fi
export KEEP_META_tty
command keep "$@"
}
alias @='keep --save'
alias @@='keep --get'
# Shell completions
. <(command keep --generate-completion zsh)
___keep_complete() {
local mode="$1"
local -a words
words=(keep "$mode" "${words[@]:1}")
((CURRENT++))
_keep
}
___keep_save_completion() { ___keep_complete --save; }
___keep_get_completion() { ___keep_complete --get; }
compdef ___keep_save_completion @
compdef ___keep_get_completion @@

358
src/args.rs Normal file
View File

@@ -0,0 +1,358 @@
use std::path::PathBuf;
use std::str::FromStr;
use clap::*;
use clap_complete::Shell;
/// Main struct for command-line arguments, parsed via Clap.
#[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None)]
pub struct Args {
#[command(flatten)]
pub mode: ModeArgs,
#[command(flatten)]
pub item: ItemArgs,
#[command(flatten)]
pub options: OptionsArgs,
#[arg(help("A list of either item IDs or tags"))]
#[arg(value_parser = clap::value_parser!(NumberOrString))]
#[arg(required = false)]
pub ids_or_tags: Vec<NumberOrString>,
}
/// Struct for mode-specific arguments, defining CLI flags for different operations.
#[derive(Parser, Debug, Clone)]
pub struct ModeArgs {
#[arg(group("mode"), help_heading("Mode Options"), short, long)]
#[arg(help("Save an item using any tags or metadata provided"))]
pub save: bool,
#[arg(group("mode"), help_heading("Mode Options"), short, long)]
#[arg(help("Get an item either by its ID or by a combination of matching tags and metadata"))]
pub get: bool,
#[arg(group("mode"), help_heading("Mode Options"), long)]
#[arg(help("Show a diff between two items by ID"))]
pub diff: bool,
#[arg(group("mode"), help_heading("Mode Options"), short, long)]
#[arg(help("List items, filtering on tags or metadata if given"))]
pub list: bool,
#[arg(group("mode"), help_heading("Mode Options"), short, long)]
#[arg(help("Delete items either by ID or by matching tags"))]
#[arg(requires = "ids_or_tags")]
pub delete: bool,
#[arg(group("mode"), help_heading("Mode Options"), short, long)]
#[arg(help("Get an item either by its ID or by a combination of matching tags and metadata"))]
pub info: bool,
#[arg(group("mode"), help_heading("Mode Options"), short('u'), long)]
#[arg(help("Update an item's tags and metadata by ID"))]
pub update: bool,
#[arg(group("mode"), help_heading("Mode Options"), short('S'), long)]
#[arg(help("Show status of directories and supported compression algorithms"))]
pub status: bool,
#[arg(group("mode"), help_heading("Mode Options"), long)]
#[arg(help("Show available plugins and their configurations"))]
pub status_plugins: bool,
#[arg(group("mode"), help_heading("Mode Options"), long)]
#[arg(help("Export items to a .keep.tar archive (requires IDs or tags)"))]
pub export: bool,
#[arg(group("mode"), help_heading("Mode Options"), long, value_name("FILE"))]
#[arg(help("Import items from a .keep.tar archive or legacy .meta.yml file"))]
pub import: Option<String>,
#[cfg(feature = "server")]
#[arg(group("mode"), help_heading("Mode Options"), long)]
#[arg(help("Start REST HTTP server"))]
pub server: bool,
#[arg(group("mode"), help_heading("Mode Options"), long)]
#[arg(help("Generate default configuration and output to stdout"))]
pub generate_config: bool,
#[arg(help_heading("Mode Options"), long)]
#[arg(help("Generate shell completion script"))]
pub generate_completion: Option<Shell>,
#[cfg(feature = "server")]
#[arg(help_heading("Server Options"), long, env("KEEP_SERVER_ADDRESS"))]
#[arg(help("Server address to bind to"))]
pub server_address: Option<String>,
#[cfg(feature = "server")]
#[arg(help_heading("Server Options"), long, env("KEEP_SERVER_PORT"))]
#[arg(help("Server port to bind to"))]
pub server_port: Option<u16>,
#[cfg(feature = "server")]
#[arg(help_heading("Server Options"), long, env("KEEP_SERVER_CERT"))]
#[arg(help("Path to TLS certificate file (PEM) for HTTPS"))]
pub server_cert: Option<PathBuf>,
#[cfg(feature = "server")]
#[arg(help_heading("Server Options"), long, env("KEEP_SERVER_KEY"))]
#[arg(help("Path to TLS private key file (PEM) for HTTPS"))]
pub server_key: Option<PathBuf>,
}
/// Represents a meta plugin argument with optional JSON config.
///
/// Parsed from `name` or `name:{"options":{...},"outputs":{...}}` syntax.
#[derive(Debug, Clone)]
pub struct MetaPluginArg {
pub name: String,
pub options: Option<serde_json::Value>,
}
impl FromStr for MetaPluginArg {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((name, json_str)) = s.split_once(':') {
let value: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| anyhow::anyhow!("Invalid JSON for meta plugin '{}': {}", name, e))?;
Ok(MetaPluginArg {
name: name.to_string(),
options: Some(value),
})
} else {
Ok(MetaPluginArg {
name: s.to_string(),
options: None,
})
}
}
}
/// Represents a metadata key-value argument.
///
/// Parsed from `key=value` (set) or `key` (delete/filter by existence).
#[derive(Debug, Clone)]
pub enum MetaArg {
/// Set metadata with a value.
Set { key: String, value: String },
/// Bare key without a value (delete in update mode, filter by existence otherwise).
Key(String),
}
impl MetaArg {
/// Returns the key.
pub fn key(&self) -> &str {
match self {
MetaArg::Set { key, .. } | MetaArg::Key(key) => key,
}
}
/// Returns the value if this is a Set variant.
pub fn value(&self) -> Option<&str> {
match self {
MetaArg::Set { value, .. } => Some(value),
MetaArg::Key(_) => None,
}
}
}
impl FromStr for MetaArg {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some((key, value)) = s.split_once('=') {
Ok(MetaArg::Set {
key: key.to_string(),
value: value.to_string(),
})
} else {
Ok(MetaArg::Key(s.to_string()))
}
}
}
/// Struct for item-specific arguments, such as compression and plugins.
#[derive(Parser, Debug, Clone)]
pub struct ItemArgs {
#[arg(help_heading("Item Options"), short, long, env("KEEP_COMPRESSION"))]
#[arg(help("Compression algorithm to use when saving items"))]
pub compression: Option<String>,
#[arg(
help_heading("Item Options"),
short('M'),
long = "meta-plugin",
value_parser = clap::value_parser!(MetaPluginArg),
env("KEEP_META_PLUGINS")
)]
#[arg(help("Meta plugin to use (repeatable): name or name:{json}"))]
pub meta_plugins: Vec<MetaPluginArg>,
#[arg(help_heading("Item Options"), long)]
#[arg(help("Metadata key=value to set (or key to delete in --update)"))]
pub meta: Vec<String>,
#[arg(help_heading("Item Options"), long, env("KEEP_FILTERS"))]
#[arg(help("Filter string to apply to content when getting items"))]
pub filters: Option<String>,
#[arg(help_heading("Export Options"), long, default_value = "{name}_{ts}")]
#[arg(help("Template for export tar filename (appends .keep.tar). Variables: {name} {ts}"))]
pub export_filename_format: String,
#[arg(help_heading("Export Options"), long, value_name("NAME"))]
#[arg(help("Export name used for {name} variable (default: export_<common-tags>)"))]
pub export_name: Option<String>,
#[arg(help_heading("Import Options"), long, value_name("DATA_FILE"))]
#[arg(help("Data file for import (reads from stdin if omitted)"))]
pub import_data_file: Option<PathBuf>,
}
/// Struct for general options, including verbosity, paths, and output settings.
#[derive(Parser, Debug, Default, Clone)]
pub struct OptionsArgs {
#[arg(long, env("KEEP_CONFIG"))]
#[arg(help("Specify the configuration file to use"))]
pub config: Option<PathBuf>,
#[arg(long, env("KEEP_DIR"))]
#[arg(help("Specify the directory to use for storage"))]
pub dir: Option<PathBuf>,
#[arg(
long,
env("KEEP_LIST_FORMAT"),
default_value("id,time,size,meta:text_line_count,tags,meta:hostname_short,meta:command")
)]
#[arg(help("A comma separated list of columns to display with --list"))]
pub list_format: String,
#[arg(short('H'), long)]
#[arg(help("Display file sizes with units"))]
pub human_readable: bool,
#[arg(long)]
#[arg(help("Only output item IDs (for scripting)"))]
pub ids_only: bool,
#[arg(short, long, action = clap::ArgAction::Count, conflicts_with("quiet"))]
#[arg(help("Increase message verbosity, can be given more than once"))]
pub verbose: u8,
#[arg(short, long)]
#[arg(help("Do not show any messages"))]
pub quiet: bool,
#[arg(long, value_enum, default_value("table"))]
#[arg(help("Output format (only works with --info, --status, --list)"))]
pub output_format: Option<String>,
#[cfg(feature = "server")]
#[arg(help_heading("Server Options"), long, env("KEEP_SERVER_PASSWORD"))]
#[arg(help("Password for server authentication (requires --server)"))]
pub server_password: Option<String>,
#[cfg(feature = "server")]
#[arg(help_heading("Server Options"), long, env("KEEP_SERVER_PASSWORD_HASH"))]
#[arg(help("Password hash for server authentication (requires --server)"))]
pub server_password_hash: Option<String>,
#[cfg(feature = "server")]
#[arg(help_heading("Server Options"), long, env("KEEP_SERVER_USERNAME"))]
#[arg(help(
"Username for server Basic authentication (requires --server, defaults to 'keep')"
))]
pub server_username: Option<String>,
#[cfg(feature = "server")]
#[arg(help_heading("Server Options"), long, env("KEEP_SERVER_JWT_SECRET"))]
#[arg(help("JWT secret for token-based authentication (requires --server)"))]
pub server_jwt_secret: Option<String>,
#[cfg(feature = "server")]
#[arg(
help_heading("Server Options"),
long,
env("KEEP_SERVER_JWT_SECRET_FILE")
)]
#[arg(help("Path to file containing JWT secret (requires --server)"))]
pub server_jwt_secret_file: Option<PathBuf>,
#[cfg(feature = "server")]
#[arg(help_heading("Server Options"), long, env("KEEP_SERVER_MAX_BODY_SIZE"))]
#[arg(help("Maximum request body size in bytes (requires --server, default: unlimited)"))]
pub server_max_body_size: Option<u64>,
#[cfg(feature = "client")]
#[arg(long, env("KEEP_CLIENT_URL"), help_heading("Client Options"))]
#[arg(help("Remote keep server URL for client mode"))]
pub client_url: Option<String>,
#[cfg(feature = "client")]
#[arg(long, env("KEEP_CLIENT_PASSWORD"), help_heading("Client Options"))]
#[arg(help("Password for remote keep server authentication"))]
pub client_password: Option<String>,
#[cfg(feature = "client")]
#[arg(long, env("KEEP_CLIENT_USERNAME"), help_heading("Client Options"))]
#[arg(help("Username for remote keep server authentication (defaults to 'keep')"))]
pub client_username: Option<String>,
#[cfg(feature = "client")]
#[arg(long, env("KEEP_CLIENT_JWT"), help_heading("Client Options"))]
#[arg(help("JWT token for remote keep server authentication"))]
pub client_jwt: Option<String>,
#[arg(
long,
help("Force output even when binary data would be sent to a TTY")
)]
pub force: bool,
}
/// Enum for representing either a number (item ID) or a string (tag).
#[derive(Debug, Clone)]
pub enum NumberOrString {
Number(i64),
Str(String),
}
impl FromStr for NumberOrString {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(s.parse::<i64>()
.map(NumberOrString::Number)
.unwrap_or_else(|_| NumberOrString::Str(s.to_string())))
}
}
/// Validates the parsed arguments based on mode constraints.
///
/// # Returns
///
/// `Result<(), String>` - Ok if valid, or an error message string.
impl Args {
/// Validate the arguments based on the selected mode
pub fn validate(&self) -> Result<(), String> {
// Check if --delete is used and ids_or_tags is empty
if self.mode.delete && self.ids_or_tags.is_empty() {
return Err("At least one ID is required when using --delete".to_string());
}
// Check if --delete is used and any of the ids_or_tags are tags (strings)
if self.mode.delete {
for item in &self.ids_or_tags {
if let NumberOrString::Str(_) = item {
return Err("Tags are not supported for --delete, only IDs".to_string());
}
}
}
Ok(())
}
}

514
src/client.rs Normal file
View File

@@ -0,0 +1,514 @@
use crate::services::{ItemInfo, error::CoreError};
use base64::Engine;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::io::Read;
/// Percent-encode a value for use in a URL query string.
fn url_encode(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 3);
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(byte as char);
}
_ => {
result.push('%');
result.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
result.push(char::from_digit((byte & 0xF) as u32, 16).unwrap());
}
}
}
result
}
fn append_query_params(url: &mut String, params: &[(&str, &str)]) {
if !params.is_empty() {
url.push('?');
for (i, (key, value)) in params.iter().enumerate() {
if i > 0 {
url.push('&');
}
url.push_str(&format!("{}={}", url_encode(key), url_encode(value)));
}
}
}
pub struct KeepClient {
base_url: String,
agent: ureq::Agent,
username: Option<String>,
password: Option<String>,
jwt: Option<String>,
}
impl KeepClient {
pub fn new(
base_url: &str,
username: Option<String>,
password: Option<String>,
jwt: Option<String>,
) -> Result<Self, CoreError> {
let base_url = base_url.trim_end_matches('/').to_string();
let agent = ureq::Agent::new_with_defaults();
Ok(Self {
base_url,
agent,
username,
password,
jwt,
})
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn username(&self) -> Option<&String> {
self.username.as_ref()
}
pub fn password(&self) -> Option<&String> {
self.password.as_ref()
}
pub fn jwt(&self) -> Option<&String> {
self.jwt.as_ref()
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
/// Get the Authorization header value for the current credentials.
///
/// JWT token is sent as `Bearer <token>`.
/// Password is sent as `Basic base64(username:password)`
/// where username defaults to "keep".
fn auth_header(&self) -> Option<String> {
if let Some(ref jwt) = self.jwt {
Some(format!("Bearer {jwt}"))
} else if let Some(ref password) = self.password {
let username = self.username.as_deref().unwrap_or("keep");
let credentials = format!("{username}:{password}");
let encoded = base64::engine::general_purpose::STANDARD.encode(&credentials);
Some(format!("Basic {encoded}"))
} else {
None
}
}
fn handle_error<T>(&self, result: Result<T, ureq::Error>) -> Result<T, CoreError> {
match result {
Ok(v) => Ok(v),
Err(ureq::Error::StatusCode(code)) => Err(CoreError::Other(anyhow::anyhow!(
"Server returned error: HTTP {}",
code
))),
Err(e) => Err(CoreError::Other(anyhow::anyhow!("Request failed: {}", e))),
}
}
pub fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, CoreError> {
let url = self.url(path);
let mut req = self.agent.get(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let body: T = self.handle_error(response.into_body().read_json())?;
Ok(body)
}
pub fn get_json_with_query<T: DeserializeOwned>(
&self,
path: &str,
params: &[(&str, &str)],
) -> Result<T, CoreError> {
let mut url = self.url(path);
append_query_params(&mut url, params);
let mut req = self.agent.get(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let body: T = self.handle_error(response.into_body().read_json())?;
Ok(body)
}
pub fn get_bytes(&self, path: &str) -> Result<Vec<u8>, CoreError> {
let url = self.url(path);
let mut req = self.agent.get(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let mut body = response.into_body();
let bytes = body
.read_to_vec()
.map_err(|e| CoreError::Other(anyhow::anyhow!("{}", e)))?;
Ok(bytes)
}
pub fn post_bytes(
&self,
path: &str,
body_bytes: &[u8],
params: &[(&str, &str)],
) -> Result<ItemInfo, CoreError> {
let mut cursor = std::io::Cursor::new(body_bytes);
self.post_stream(path, &mut cursor, params)
}
/// Stream data from a reader to the server using chunked transfer encoding.
///
/// The reader is consumed in chunks and sent to the server without buffering
/// the entire body in memory. This enables true streaming for large payloads.
pub fn post_stream(
&self,
path: &str,
body_reader: &mut dyn Read,
params: &[(&str, &str)],
) -> Result<ItemInfo, CoreError> {
let mut url = self.url(path);
append_query_params(&mut url, params);
let mut req = self.agent.post(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
req = req.header("Content-Type", "application/octet-stream");
let response = self.handle_error(req.send(ureq::SendBody::from_reader(body_reader)))?;
#[derive(serde::Deserialize)]
struct ApiResponse {
data: Option<ItemInfo>,
error: Option<String>,
}
let api_response: ApiResponse = self.handle_error(response.into_body().read_json())?;
if let Some(error) = api_response.error {
return Err(CoreError::Other(anyhow::anyhow!("Server error: {}", error)));
}
api_response
.data
.ok_or_else(|| CoreError::Other(anyhow::anyhow!("No data in response")))
}
pub fn delete(&self, path: &str) -> Result<(), CoreError> {
let url = self.url(path);
let mut req = self.agent.delete(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
self.handle_error(req.call())?;
Ok(())
}
pub fn get_status(&self) -> Result<crate::common::status::StatusInfo, CoreError> {
#[derive(serde::Deserialize)]
struct ApiResponse {
data: Option<crate::common::status::StatusInfo>,
error: Option<String>,
}
let response: ApiResponse = self.get_json("/api/status")?;
response.data.ok_or_else(|| {
CoreError::Other(anyhow::anyhow!(
"{}",
response
.error
.unwrap_or_else(|| "No status data returned".to_string())
))
})
}
pub fn get_item_info(&self, id: i64) -> Result<ItemInfo, CoreError> {
#[derive(serde::Deserialize)]
struct ApiResponse {
data: Option<ItemInfo>,
error: Option<String>,
}
let response: ApiResponse = self.get_json(&format!("/api/item/{id}/info"))?;
response.data.ok_or_else(|| {
CoreError::Other(anyhow::anyhow!(
"{}",
response
.error
.unwrap_or_else(|| "Item not found".to_string())
))
})
}
pub fn list_items(
&self,
ids: &[i64],
tags: &[String],
order: &str,
start: u64,
count: u64,
meta: &HashMap<String, Option<String>>,
) -> Result<Vec<ItemInfo>, CoreError> {
#[derive(serde::Deserialize)]
struct ApiResponse {
data: Option<Vec<ItemInfo>>,
error: Option<String>,
}
let mut params: Vec<(String, String)> = Vec::new();
params.push(("order".to_string(), order.to_string()));
params.push(("start".to_string(), start.to_string()));
params.push(("count".to_string(), count.to_string()));
if !ids.is_empty() {
params.push((
"ids".to_string(),
ids.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(","),
));
}
if !tags.is_empty() {
params.push(("tags".to_string(), tags.join(",")));
}
if !meta.is_empty() {
let meta_json = serde_json::to_string(meta).map_err(|e| {
CoreError::Other(anyhow::anyhow!("Failed to serialize meta filter: {}", e))
})?;
params.push(("meta".to_string(), meta_json));
}
let param_refs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let response: ApiResponse = self.get_json_with_query("/api/item/", &param_refs)?;
if let Some(data) = response.data {
return Ok(data);
}
if let Some(err) = response.error {
return Err(CoreError::Other(anyhow::anyhow!("Server error: {err}")));
}
Ok(Vec::new())
}
pub fn save_item(
&self,
content: &[u8],
tags: &[String],
metadata: &HashMap<String, String>,
compress: bool,
meta: bool,
) -> Result<ItemInfo, CoreError> {
let mut params: Vec<(String, String)> = Vec::new();
if !tags.is_empty() {
params.push(("tags".to_string(), tags.join(",")));
}
if !metadata.is_empty() {
let meta_json = serde_json::to_string(metadata).map_err(|e| {
CoreError::Other(anyhow::anyhow!("Failed to serialize metadata: {}", e))
})?;
params.push(("metadata".to_string(), meta_json));
}
params.push(("compress".to_string(), compress.to_string()));
params.push(("meta".to_string(), meta.to_string()));
let param_refs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
self.post_bytes("/api/item/", content, &param_refs)
}
pub fn delete_item(&self, id: i64) -> Result<(), CoreError> {
self.delete(&format!("/api/item/{id}"))
}
/// Add metadata to an existing item.
pub fn post_metadata(
&self,
id: i64,
metadata: &HashMap<String, String>,
) -> Result<(), CoreError> {
let url = self.url(&format!("/api/item/{id}/meta"));
let mut req = self.agent.post(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
req = req.header("Content-Type", "application/json");
let body = serde_json::to_vec(metadata)
.map_err(|e| CoreError::Other(anyhow::anyhow!("Failed to serialize metadata: {e}")))?;
let mut cursor = std::io::Cursor::new(body);
self.handle_error(req.send(ureq::SendBody::from_reader(&mut cursor)))?;
Ok(())
}
/// Set the uncompressed size for an item.
pub fn set_item_size(&self, id: i64, size: u64) -> Result<(), CoreError> {
let url = format!(
"{}?uncompressed_size={}",
self.url(&format!("/api/item/{id}/update")),
url_encode(&size.to_string())
);
let mut req = self.agent.post(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
self.handle_error(req.send(ureq::SendBody::from_reader(&mut std::io::empty())))?;
Ok(())
}
pub fn get_item_content_raw(&self, id: i64) -> Result<(Vec<u8>, String), CoreError> {
let (mut reader, compression) = self.get_item_content_stream(id)?;
let mut bytes = Vec::new();
reader
.read_to_end(&mut bytes)
.map_err(|e| CoreError::Other(anyhow::anyhow!("{}", e)))?;
Ok((bytes, compression))
}
/// Get a streaming reader for item content without decompression.
///
/// Returns a reader over the HTTP response body and the compression type
/// from the X-Keep-Compression header. The caller can stream through
/// decompression readers without buffering the entire file in memory.
pub fn get_item_content_stream(&self, id: i64) -> Result<(Box<dyn Read>, String), CoreError> {
let url = format!(
"{}?decompress=false",
self.url(&format!("/api/item/{id}/content"))
);
let mut req = self.agent.get(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let compression = response
.headers()
.get("X-Keep-Compression")
.and_then(|v| v.to_str().ok())
.unwrap_or("raw")
.to_string();
let reader = response.into_body().into_reader();
Ok((Box::new(reader), compression))
}
pub fn diff_items(&self, id_a: i64, id_b: i64) -> Result<Vec<String>, CoreError> {
#[derive(serde::Deserialize)]
struct ApiResponse {
data: Option<Vec<String>>,
}
let params = [("id_a", id_a.to_string()), ("id_b", id_b.to_string())];
let param_refs: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
let response: ApiResponse = self.get_json_with_query("/api/diff", &param_refs)?;
Ok(response.data.unwrap_or_default())
}
/// Export items to a tar archive, streaming the response to a file.
///
/// # Arguments
///
/// * `ids` - Item IDs to export (mutually exclusive with tags).
/// * `tags` - Tags to search for items (mutually exclusive with ids).
/// * `dest` - Destination file path.
pub fn export_items_to_file(
&self,
ids: &[i64],
tags: &[String],
dest: &std::path::Path,
) -> Result<(), CoreError> {
let mut params: Vec<(String, String)> = Vec::new();
if !ids.is_empty() {
let id_strs: Vec<String> = ids.iter().map(|id| id.to_string()).collect();
params.push(("ids".to_string(), id_strs.join(",")));
}
if !tags.is_empty() {
params.push(("tags".to_string(), tags.join(",")));
}
let param_refs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let mut url = self.url("/api/export");
append_query_params(&mut url, &param_refs);
let mut req = self.agent.get(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
let response = self.handle_error(req.call())?;
let mut reader = response.into_body().into_reader();
let mut file = std::fs::File::create(dest).map_err(CoreError::Io)?;
let mut buf = [0u8; crate::common::PIPESIZE];
loop {
let n = reader.read(&mut buf).map_err(CoreError::Io)?;
if n == 0 {
break;
}
std::io::Write::write_all(&mut file, &buf[..n]).map_err(CoreError::Io)?;
}
Ok(())
}
/// Import items from a tar archive, streaming the file to the server.
///
/// # Arguments
///
/// * `tar_path` - Path to the `.keep.tar` file.
///
/// # Returns
///
/// A list of newly assigned item IDs.
pub fn import_tar_file(&self, tar_path: &std::path::Path) -> Result<Vec<i64>, CoreError> {
#[derive(serde::Deserialize)]
struct ApiResponse {
data: Option<ImportResponse>,
error: Option<String>,
}
#[derive(serde::Deserialize)]
struct ImportResponse {
ids: Vec<i64>,
}
let mut file = std::fs::File::open(tar_path).map_err(CoreError::Io)?;
let url = self.url("/api/import");
let mut req = self.agent.post(&url);
if let Some(ref auth) = self.auth_header() {
req = req.header("Authorization", auth);
}
req = req.header("Content-Type", "application/x-tar");
let response = self.handle_error(req.send(ureq::SendBody::from_reader(&mut file)))?;
let body = response
.into_body()
.read_to_string()
.map_err(|e| CoreError::InvalidInput(format!("Cannot read response: {e}")))?;
let api_response: ApiResponse = serde_json::from_str(&body)
.map_err(|e| CoreError::InvalidInput(format!("Cannot parse response: {e}")))?;
if let Some(error) = api_response.error {
return Err(CoreError::InvalidInput(error));
}
Ok(api_response.data.map(|d| d.ids).unwrap_or_default())
}
}

View File

@@ -1,217 +0,0 @@
use std::io::Read;
/// Detect if data is binary or text
/// Returns true if data is likely binary, false if likely text
pub fn is_binary(data: &[u8]) -> bool {
if data.is_empty() {
return false;
}
// First check for known binary file signatures
if has_binary_signature(data) {
return true;
}
// Check for UTF-16 BOM (text)
if data.len() >= 2 {
if (data[0] == 0xFF && data[1] == 0xFE) || (data[0] == 0xFE && data[1] == 0xFF) {
return false; // UTF-16 with BOM is text
}
}
// Check for UTF-8 BOM (text)
if data.len() >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
return false; // UTF-8 with BOM is text
}
// Check if it's valid UTF-8
if std::str::from_utf8(data).is_ok() {
// Valid UTF-8, check printable character ratio
return calculate_printable_ratio(data) < 0.7;
}
// Not valid UTF-8, check if it might be UTF-16 without BOM
if looks_like_utf16(data) {
return false; // Likely UTF-16 text
}
// Check for TAR format (special case with no magic number)
if looks_like_tar(data) {
return true;
}
// Final fallback: check printable character ratio
// For 1KB of random data, we expect very few printable characters
calculate_printable_ratio(data) < 0.7
}
/// Check for known binary file signatures
fn has_binary_signature(data: &[u8]) -> bool {
// Define binary file signatures with their minimum required lengths
let signatures: &[(&[u8], usize)] = &[
// Image formats
(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 8), // PNG
(&[0xFF, 0xD8, 0xFF], 3), // JPEG (various subtypes)
(&[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], 6), // GIF87a
(&[0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 6), // GIF89a
(&[0x42, 0x4D], 2), // BMP
(&[0x00, 0x00, 0x01, 0x00], 4), // ICO
(&[0x49, 0x49, 0x2A, 0x00], 4), // TIFF (little endian)
(&[0x4D, 0x4D, 0x00, 0x2A], 4), // TIFF (big endian)
(&[0x52, 0x49, 0x46, 0x46], 4), // WebP (RIFF container)
(&[0x00, 0x00, 0x00, 0x0C, 0x6A, 0x50, 0x20, 0x20], 8), // JPEG 2000
// Audio/Video formats
(&[0x49, 0x44, 0x33], 3), // MP3 with ID3v2
(&[0xFF, 0xFB], 2), // MP3
(&[0xFF, 0xF3], 2), // MP3
(&[0xFF, 0xF2], 2), // MP3
(&[0x4F, 0x67, 0x67, 0x53], 4), // OGG
(&[0x66, 0x74, 0x79, 0x70], 4), // MP4/M4A/MOV (at offset 4)
(&[0x52, 0x49, 0x46, 0x46], 4), // WAV/AVI (RIFF)
(&[0x46, 0x4C, 0x56], 3), // FLV
(&[0x1A, 0x45, 0xDF, 0xA3], 4), // MKV/WebM
// Archive formats
(&[0x50, 0x4B, 0x03, 0x04], 4), // ZIP
(&[0x50, 0x4B, 0x05, 0x06], 4), // ZIP (empty)
(&[0x50, 0x4B, 0x07, 0x08], 4), // ZIP (spanned)
(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00], 7), // RAR v1.5+
(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00], 8), // RAR v5.0+
(&[0x1F, 0x8B], 2), // GZIP
(&[0x42, 0x5A, 0x68], 3), // BZIP2
(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00], 6), // XZ
(&[0x28, 0xB5, 0x2F, 0xFD], 4), // Zstandard
(&[0x04, 0x22, 0x4D, 0x18], 4), // LZ4
(&[0x1F, 0x9D], 2), // LZW compressed
(&[0x1F, 0xA0], 2), // LZH compressed
(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 6), // 7-Zip
// Document formats
(&[0x25, 0x50, 0x44, 0x46], 4), // PDF
(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1], 8), // MS Office (OLE)
(&[0x50, 0x4B, 0x03, 0x04], 4), // Office Open XML (also ZIP)
(&[0x7B, 0x5C, 0x72, 0x74, 0x66], 5), // RTF
// Executables and object files
(&[0x7F, 0x45, 0x4C, 0x46], 4), // ELF
(&[0x4D, 0x5A], 2), // Windows PE/DOS
(&[0xCA, 0xFE, 0xBA, 0xBE], 4), // Mach-O (big endian)
(&[0xFE, 0xED, 0xFA, 0xCE], 4), // Mach-O 32-bit (little endian)
(&[0xFE, 0xED, 0xFA, 0xCF], 4), // Mach-O 64-bit (little endian)
(&[0xCE, 0xFA, 0xED, 0xFE], 4), // Mach-O 32-bit (big endian)
(&[0xCF, 0xFA, 0xED, 0xFE], 4), // Mach-O 64-bit (big endian)
(&[0xCA, 0xFE, 0xBA, 0xBE], 4), // Java class file
(&[0xDE, 0xC0, 0x17, 0x0B], 4), // Dalvik executable
// Database formats
(&[0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6F, 0x72, 0x6D, 0x61, 0x74, 0x20, 0x33, 0x00], 16), // SQLite
(&[0x00, 0x01, 0x00, 0x00], 4), // Palm Database
// Font formats
(&[0x00, 0x01, 0x00, 0x00, 0x00], 5), // TrueType
(&[0x4F, 0x54, 0x54, 0x4F], 4), // OpenType
(&[0x77, 0x4F, 0x46, 0x46], 4), // WOFF
(&[0x77, 0x4F, 0x46, 0x32], 4), // WOFF2
// Virtual machine formats
(&[0x76, 0x6D, 0x64, 0x6B], 4), // VMDK
(&[0x3C, 0x3C, 0x3C, 0x20, 0x4F, 0x72, 0x61, 0x63, 0x6C, 0x65, 0x20, 0x56, 0x4D, 0x20, 0x56, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6C, 0x42, 0x6F, 0x78, 0x20, 0x44, 0x69, 0x73, 0x6B, 0x20, 0x49, 0x6D, 0x61, 0x67, 0x65, 0x20, 0x3E, 0x3E, 0x3E], 39), // VirtualBox VDI
// Disk image formats
(&[0xEB, 0x3C, 0x90], 3), // FAT12/16/32
(&[0xEB, 0x58, 0x90], 3), // FAT32
(&[0x55, 0xAA], 2), // Boot sector (at offset 510)
// Other binary formats
(&[0x21, 0x3C, 0x61, 0x72, 0x63, 0x68, 0x3E, 0x0A], 8), // AR archive
(&[0x78, 0x01], 2), // zlib (default compression)
(&[0x78, 0x9C], 2), // zlib (best compression)
(&[0x78, 0xDA], 2), // zlib (fast compression)
(&[0x62, 0x76, 0x78, 0x32], 4), // LZFSE
];
for (signature, min_len) in signatures {
if data.len() >= *min_len && data.starts_with(signature) {
return true;
}
}
// Special case: check for ftyp box in MP4/MOV files (at offset 4)
if data.len() >= 8 && &data[4..8] == b"ftyp" {
return true;
}
false
}
/// Check if data looks like UTF-16 without BOM
fn looks_like_utf16(data: &[u8]) -> bool {
if data.len() < 4 || data.len() % 2 != 0 {
return false;
}
let mut zero_count = 0;
let pairs = data.len() / 2;
// Check if every other byte is zero (indicating UTF-16)
for i in 0..pairs {
if data[i * 2 + 1] == 0 {
zero_count += 1;
}
}
// If more than 50% of odd positions are zero, might be UTF-16
zero_count as f64 / pairs as f64 > 0.5
}
/// Check if data looks like a TAR archive
fn looks_like_tar(data: &[u8]) -> bool {
if data.len() < 512 {
return false;
}
// TAR header structure validation
// Filename should not start with null
if data[0] == 0 {
return false;
}
// Check file mode field (should be octal digits)
for i in 100..108 {
if data[i] != 0 && (data[i] < b'0' || data[i] > b'7') && data[i] != b' ' {
return false;
}
}
// Check checksum field (should be octal digits or spaces)
for i in 148..156 {
if data[i] != 0 && (data[i] < b'0' || data[i] > b'7') && data[i] != b' ' {
return false;
}
}
// Check magic field for POSIX TAR
if data.len() >= 265 {
let magic = &data[257..262];
if magic == b"ustar" {
return true;
}
}
// Additional heuristic: check if the structure looks reasonable
let has_reasonable_structure =
data[0] != 0 && // Filename starts
data[100..108].iter().all(|&b| b == 0 || (b >= b'0' && b <= b'7') || b == b' '); // Mode field
has_reasonable_structure
}
/// Calculate the ratio of printable characters in the data
fn calculate_printable_ratio(data: &[u8]) -> f64 {
let printable_count = data.iter().filter(|&&b| {
b.is_ascii_graphic() || b.is_ascii_whitespace()
}).count();
printable_count as f64 / data.len() as f64
}

253
src/common/is_binary.rs Normal file
View File

@@ -0,0 +1,253 @@
/// Detect if data is binary or text
/// Returns true if data is likely binary, false if likely text
pub fn is_binary(data: &[u8]) -> bool {
if data.is_empty() {
return false;
}
// First check for known binary file signatures
if has_binary_signature(data) {
return true;
}
// Check for UTF-16 BOM (text)
if data.len() >= 2
&& ((data[0] == 0xFF && data[1] == 0xFE) || (data[0] == 0xFE && data[1] == 0xFF))
{
return false; // UTF-16 with BOM is text
}
// Check for UTF-8 BOM (text)
if data.len() >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
return false; // UTF-8 with BOM is text
}
// Check if it's valid UTF-8
if std::str::from_utf8(data).is_ok() {
// Valid UTF-8, check printable character ratio
return calculate_printable_ratio(data) < 0.7;
}
// Not valid UTF-8, check if it might be UTF-16 without BOM
if looks_like_utf16(data) {
return false; // Likely UTF-16 text
}
// Check for TAR format (special case with no magic number)
if looks_like_tar(data) {
return true;
}
// Final fallback: check printable character ratio
// For 1KB of random data, we expect very few printable characters
calculate_printable_ratio(data) < 0.7
}
/// Check for known binary file signatures
fn has_binary_signature(data: &[u8]) -> bool {
// Define binary file signatures with their minimum required lengths
let signatures: &[(&[u8], usize)] = &[
// Image formats
(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 8), // PNG
(&[0xFF, 0xD8, 0xFF], 3), // JPEG (various subtypes)
(&[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], 6), // GIF87a
(&[0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 6), // GIF89a
(&[0x42, 0x4D], 2), // BMP
(&[0x00, 0x00, 0x01, 0x00], 4), // ICO
(&[0x49, 0x49, 0x2A, 0x00], 4), // TIFF (little endian)
(&[0x4D, 0x4D, 0x00, 0x2A], 4), // TIFF (big endian)
(&[0x52, 0x49, 0x46, 0x46], 4), // WebP (RIFF container)
(&[0x00, 0x00, 0x00, 0x0C, 0x6A, 0x50, 0x20, 0x20], 8), // JPEG 2000
// Audio/Video formats
(&[0x49, 0x44, 0x33], 3), // MP3 with ID3v2
(&[0xFF, 0xFB], 2), // MP3
(&[0xFF, 0xF3], 2), // MP3
(&[0xFF, 0xF2], 2), // MP3
(&[0x4F, 0x67, 0x67, 0x53], 4), // OGG
(&[0x66, 0x74, 0x79, 0x70], 4), // MP4/M4A/MOV (at offset 4)
(&[0x52, 0x49, 0x46, 0x46], 4), // WAV/AVI (RIFF)
(&[0x46, 0x4C, 0x56], 3), // FLV
(&[0x1A, 0x45, 0xDF, 0xA3], 4), // MKV/WebM
// Archive formats
(&[0x50, 0x4B, 0x03, 0x04], 4), // ZIP
(&[0x50, 0x4B, 0x05, 0x06], 4), // ZIP (empty)
(&[0x50, 0x4B, 0x07, 0x08], 4), // ZIP (spanned)
(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x00], 7), // RAR v1.5+
(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07, 0x01, 0x00], 8), // RAR v5.0+
(&[0x1F, 0x8B], 2), // GZIP
(&[0x42, 0x5A, 0x68], 3), // BZIP2
(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00], 6), // XZ
(&[0x28, 0xB5, 0x2F, 0xFD], 4), // Zstandard
(&[0x04, 0x22, 0x4D, 0x18], 4), // LZ4
(&[0x1F, 0x9D], 2), // LZW compressed
(&[0x1F, 0xA0], 2), // LZH compressed
(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C], 6), // 7-Zip
// Document formats
(&[0x25, 0x50, 0x44, 0x46], 4), // PDF
(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1], 8), // MS Office (OLE)
(&[0x50, 0x4B, 0x03, 0x04], 4), // Office Open XML (also ZIP)
(&[0x7B, 0x5C, 0x72, 0x74, 0x66], 5), // RTF
// Executables and object files
(&[0x7F, 0x45, 0x4C, 0x46], 4), // ELF
(&[0x4D, 0x5A], 2), // Windows PE/DOS
(&[0xCA, 0xFE, 0xBA, 0xBE], 4), // Mach-O (big endian)
(&[0xFE, 0xED, 0xFA, 0xCE], 4), // Mach-O 32-bit (little endian)
(&[0xFE, 0xED, 0xFA, 0xCF], 4), // Mach-O 64-bit (little endian)
(&[0xCE, 0xFA, 0xED, 0xFE], 4), // Mach-O 32-bit (big endian)
(&[0xCF, 0xFA, 0xED, 0xFE], 4), // Mach-O 64-bit (big endian)
(&[0xCA, 0xFE, 0xBA, 0xBE], 4), // Java class file
(&[0xDE, 0xC0, 0x17, 0x0B], 4), // Dalvik executable
// Database formats
(
&[
0x53, 0x51, 0x4C, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6F, 0x72, 0x6D, 0x61, 0x74, 0x20,
0x33, 0x00,
],
16,
), // SQLite
(&[0x00, 0x01, 0x00, 0x00], 4), // Palm Database
// Font formats
(&[0x00, 0x01, 0x00, 0x00, 0x00], 5), // TrueType
(&[0x4F, 0x54, 0x54, 0x4F], 4), // OpenType
(&[0x77, 0x4F, 0x46, 0x46], 4), // WOFF
(&[0x77, 0x4F, 0x46, 0x32], 4), // WOFF2
// Virtual machine formats
(&[0x76, 0x6D, 0x64, 0x6B], 4), // VMDK
(
&[
0x3C, 0x3C, 0x3C, 0x20, 0x4F, 0x72, 0x61, 0x63, 0x6C, 0x65, 0x20, 0x56, 0x4D, 0x20,
0x56, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6C, 0x42, 0x6F, 0x78, 0x20, 0x44, 0x69, 0x73,
0x6B, 0x20, 0x49, 0x6D, 0x61, 0x67, 0x65, 0x20, 0x3E, 0x3E, 0x3E,
],
39,
), // VirtualBox VDI
// Disk image formats
(&[0xEB, 0x3C, 0x90], 3), // FAT12/16/32
(&[0xEB, 0x58, 0x90], 3), // FAT32
(&[0x55, 0xAA], 2), // Boot sector (at offset 510)
// Other binary formats
(&[0x21, 0x3C, 0x61, 0x72, 0x63, 0x68, 0x3E, 0x0A], 8), // AR archive
(&[0x78, 0x01], 2), // zlib (default compression)
(&[0x78, 0x9C], 2), // zlib (best compression)
(&[0x78, 0xDA], 2), // zlib (fast compression)
(&[0x62, 0x76, 0x78, 0x32], 4), // LZFSE
];
for (signature, min_len) in signatures {
if data.len() >= *min_len && data.starts_with(signature) {
return true;
}
}
// Special case: check for ftyp box in MP4/MOV files (at offset 4)
if data.len() >= 8 && &data[4..8] == b"ftyp" {
return true;
}
false
}
/// Check if data looks like UTF-16 without BOM
fn looks_like_utf16(data: &[u8]) -> bool {
if data.len() < 4 || data.len() % 2 != 0 {
return false;
}
// Check if it could be UTF-16 by looking at null patterns
let mut null_pairs = 0;
let max_checks = std::cmp::min(data.len() / 2, 50); // Check up to 50 character pairs
for i in 0..max_checks {
if data[i * 2 + 1] == 0 {
null_pairs += 1;
}
}
// If most high bytes are zero, it's likely UTF-16
if max_checks > 0 && null_pairs as f64 / max_checks as f64 > 0.7 {
return true;
}
// Also check the reverse pattern (little-endian UTF-16)
let mut null_pairs_reverse = 0;
for i in 0..max_checks {
if i * 2 + 1 < data.len() && data[i * 2] == 0 {
null_pairs_reverse += 1;
}
}
null_pairs_reverse as f64 / max_checks as f64 > 0.7
}
/// Check if data looks like a TAR archive
fn looks_like_tar(data: &[u8]) -> bool {
if data.len() < 512 {
return false;
}
// TAR header structure validation
// Filename should not start with null
if data[0] == 0 {
return false;
}
// Check file mode field (should be octal digits)
for byte in data.iter().skip(100).take(8) {
if *byte != 0 && !(b'0'..=b'7').contains(byte) && *byte != b' ' {
return false;
}
}
// Check checksum field (should be octal digits or spaces)
for &b in &data[148..156] {
if b != 0 && !(b'0'..=b'7').contains(&b) && b != b' ' {
return false;
}
}
// Check magic field for POSIX TAR
if data.len() >= 265 {
let magic = &data[257..262];
if magic == b"ustar" {
return true;
}
}
// Additional heuristic: check if the structure looks reasonable
// Mode field
data[0] != 0 && // Filename starts
data[100..108].iter().all(|&b| b == 0 || (b'0'..=b'7').contains(&b) || b == b' ')
}
/// Calculate the ratio of printable characters in the data
fn calculate_printable_ratio(data: &[u8]) -> f64 {
let printable_count = data
.iter()
.filter(|&&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
.count();
printable_count as f64 / data.len() as f64
}
/// Check if content is binary, using metadata as a fast path.
///
/// First checks for a "text" metadata field:
/// - "false" means binary
/// - "true" means text
/// - Absent or other values fall back to byte sampling
///
/// # Arguments
///
/// * `metadata` - Key-value metadata map (e.g., from `meta_as_map()`)
/// * `data` - Byte sample to analyze if metadata is inconclusive
pub fn is_content_binary_from_metadata(
metadata: &std::collections::HashMap<String, String>,
data: &[u8],
) -> bool {
if let Some(text_val) = metadata.get("text") {
text_val == "false"
} else {
is_binary(data)
}
}

91
src/common/mod.rs Normal file
View File

@@ -0,0 +1,91 @@
pub mod is_binary;
/// Detects if data is binary or text based on signatures and printable ratios.
pub mod status;
/// Plugin schema types and discovery functions.
pub mod schema;
/// Standard buffer size for I/O operations (8KB)
pub const PIPESIZE: usize = 8192;
/// Reads chunks from `reader` until EOF, passing each chunk to `f`.
///
/// Uses a fixed PIPESIZE buffer to ensure bounded memory usage.
pub fn stream_copy<R: std::io::Read + ?Sized>(
reader: &mut R,
mut f: impl FnMut(&[u8]) -> std::io::Result<()>,
) -> std::io::Result<()> {
let mut buffer = [0u8; PIPESIZE];
loop {
let n = reader.read(&mut buffer)?;
if n == 0 {
break;
}
f(&buffer[..n])?;
}
Ok(())
}
/// Reads content from a reader with offset and length bounds.
///
/// Skips `offset` bytes from the reader, then reads up to `length` bytes
/// (or all remaining if `length` is 0). Uses PIPESIZE buffers throughout.
///
/// # Arguments
///
/// * `reader` - The source reader positioned at the start.
/// * `offset` - Number of bytes to skip before reading.
/// * `length` - Maximum bytes to read (0 = read all remaining).
/// * `content_len` - Total content size (used to cap skip/read amounts).
///
/// # Returns
///
/// A `Vec<u8>` containing the requested byte range.
pub fn read_with_bounds<R: std::io::Read>(
reader: &mut R,
offset: u64,
length: u64,
content_len: u64,
) -> std::io::Result<Vec<u8>> {
// Skip offset bytes
let skip = std::cmp::min(offset, content_len);
let mut remaining = skip;
let mut buf = [0u8; PIPESIZE];
while remaining > 0 {
let to_read = std::cmp::min(remaining, buf.len() as u64) as usize;
match reader.read(&mut buf[..to_read]) {
Ok(0) => break,
Ok(n) => remaining -= n as u64,
Err(e) => return Err(e),
}
}
// Read bounded content
let max_bytes = if length > 0 {
std::cmp::min(length, content_len.saturating_sub(offset))
} else {
content_len.saturating_sub(offset)
};
let mut result = Vec::with_capacity(std::cmp::min(max_bytes, 64 * 1024) as usize);
let mut bytes_read = 0u64;
while bytes_read < max_bytes {
let to_read = std::cmp::min(max_bytes - bytes_read, buf.len() as u64) as usize;
match reader.read(&mut buf[..to_read]) {
Ok(0) => break,
Ok(n) => {
result.extend_from_slice(&buf[..n]);
bytes_read += n as u64;
}
Err(e) => return Err(e),
}
}
Ok(result)
}
/// Sanitize a timestamp string for use in filenames.
///
/// Replaces colons with hyphens (e.g., `2026-03-17T12:00:00Z` → `2026-03-17T12-00-00Z`).
pub fn sanitize_ts_string(ts: &str) -> String {
ts.replace(':', "-")
}

166
src/common/schema.rs Normal file
View File

@@ -0,0 +1,166 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use strum::IntoEnumIterator;
/// Value type for a plugin option.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum OptionType {
String,
Integer,
Boolean,
Any,
}
impl OptionType {
/// Infer the option type from a YAML value.
pub fn from_yaml_value(value: &serde_yaml::Value) -> Self {
match value {
serde_yaml::Value::Bool(_) => OptionType::Boolean,
serde_yaml::Value::Number(_) => OptionType::Integer,
serde_yaml::Value::String(_) => OptionType::String,
_ => OptionType::Any,
}
}
}
/// Schema for a single plugin option.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptionSchema {
pub name: String,
pub option_type: OptionType,
pub default: Option<serde_yaml::Value>,
pub required: bool,
}
/// Schema for a single plugin output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputSchema {
pub name: String,
pub description: String,
}
/// Schema describing a plugin's configuration requirements.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginSchema {
pub name: String,
pub description: String,
pub options: Vec<OptionSchema>,
pub outputs: Vec<OutputSchema>,
}
/// Gathers schemas from all registered meta plugins.
///
/// Iterates all `MetaPluginType` variants, attempts to create a default instance,
/// and collects their schemas. Plugins that fail to register (e.g., feature-gated)
/// are silently skipped.
pub fn gather_meta_plugin_schemas() -> Vec<PluginSchema> {
use crate::meta_plugin::{MetaPluginType, get_meta_plugin};
let mut schemas = Vec::new();
let mut sorted_types: Vec<MetaPluginType> = MetaPluginType::iter().collect();
sorted_types.sort_by_key(|t| t.to_string());
for plugin_type in sorted_types {
let plugin = match get_meta_plugin(plugin_type.clone(), None, None) {
Ok(p) => p,
Err(_) => continue,
};
let name = plugin.meta_type().to_string();
let options: Vec<OptionSchema> = plugin
.options()
.iter()
.map(|(key, value)| {
let option_type = OptionType::from_yaml_value(value);
let (default, required) = if value.is_null() {
(None, true)
} else {
(Some(value.clone()), false)
};
OptionSchema {
name: key.clone(),
option_type,
default,
required,
}
})
.collect();
let mut outputs: Vec<OutputSchema> = Vec::new();
for (key, value) in plugin.outputs() {
if !value.is_null() {
outputs.push(OutputSchema {
name: key.clone(),
description: key.clone(),
});
}
}
// Also include default outputs if outputs map is empty
if outputs.is_empty() {
for output_name in plugin.default_outputs() {
outputs.push(OutputSchema {
name: output_name.clone(),
description: output_name,
});
}
}
schemas.push(PluginSchema {
name,
description: plugin.description().to_string(),
options,
outputs,
});
}
schemas
}
/// Gathers schemas from all registered filter plugins.
///
/// Uses the global filter plugin registry to discover all registered filters,
/// creates a default instance of each, and collects their option schemas.
pub fn gather_filter_plugin_schemas() -> Vec<PluginSchema> {
use crate::services::filter_service::get_available_filter_plugins;
let plugins = get_available_filter_plugins().unwrap_or_default();
let mut schemas: Vec<PluginSchema> = plugins
.into_iter()
.map(|(name, creator)| {
let plugin = creator();
let options: Vec<OptionSchema> = plugin
.options()
.iter()
.map(|opt| {
let option_type = match &opt.default {
Some(serde_json::Value::Bool(_)) => OptionType::Boolean,
Some(serde_json::Value::Number(_)) => OptionType::Integer,
Some(serde_json::Value::String(_)) => OptionType::String,
_ => OptionType::Any,
};
OptionSchema {
name: opt.name.clone(),
option_type,
default: opt.default.as_ref().map(|v| {
// Convert serde_json::Value to serde_yaml::Value
serde_yaml::to_value(v).unwrap_or(serde_yaml::Value::Null)
}),
required: opt.required,
}
})
.collect();
PluginSchema {
name: name.clone(),
description: plugin.description().to_string(),
options,
outputs: Vec::new(),
}
})
.collect();
schemas.sort_by(|a, b| a.name.cmp(&b.name));
schemas
}

224
src/common/status.rs Normal file
View File

@@ -0,0 +1,224 @@
use std::path::PathBuf;
use strum::IntoEnumIterator;
#[cfg(feature = "server")]
use utoipa::ToSchema;
use crate::compression_engine::{CompressionType, get_compression_engine};
use crate::meta_plugin::MetaPluginType;
use crate::filter_plugin::FilterOption;
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
#[cfg_attr(feature = "server", derive(ToSchema))]
pub struct FilterPluginInfo {
pub name: String,
pub options: Vec<FilterOption>,
pub description: String,
}
#[derive(serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "server", derive(ToSchema))]
pub struct StatusInfo {
pub paths: PathInfo,
pub compression: Vec<CompressionInfo>,
pub meta_plugins: std::collections::HashMap<String, MetaPluginInfo>,
pub enabled_meta_plugins: Vec<String>,
pub filter_plugins: Vec<FilterPluginInfo>,
pub configured_meta_plugins: Option<Vec<crate::config::MetaPluginConfig>>,
}
impl Default for StatusInfo {
fn default() -> Self {
Self {
paths: PathInfo {
data: String::new(),
database: String::new(),
},
compression: Vec::new(),
meta_plugins: std::collections::HashMap::new(),
enabled_meta_plugins: Vec::new(),
filter_plugins: Vec::new(),
configured_meta_plugins: None,
}
}
}
#[derive(serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "server", derive(ToSchema))]
pub struct PathInfo {
pub data: String,
pub database: String,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
#[cfg_attr(feature = "server", derive(ToSchema))]
pub struct CompressionInfo {
#[serde(rename = "type")]
pub compression_type: String,
pub found: bool,
pub default: bool,
pub binary: String,
pub compress: String,
pub decompress: String,
}
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
#[cfg_attr(feature = "server", derive(ToSchema))]
pub struct MetaPluginInfo {
pub meta_name: String,
pub outputs: std::collections::HashMap<String, serde_yaml::Value>,
pub options: std::collections::HashMap<String, serde_yaml::Value>,
}
pub fn generate_status_info(
data_path: PathBuf,
db_path: PathBuf,
enabled_meta_plugins: &[MetaPluginType],
enabled_compression_type: Option<CompressionType>,
) -> anyhow::Result<StatusInfo> {
log::debug!("STATUS: Starting status info generation");
let path_info = PathInfo {
data: data_path
.into_os_string()
.into_string()
.map_err(|_| anyhow::anyhow!("Unable to convert data path to string"))?,
database: db_path
.into_os_string()
.into_string()
.map_err(|_| anyhow::anyhow!("Unable to convert DB path to string"))?,
};
let _default_type = crate::compression_engine::default_compression_type();
let mut compression_info = Vec::with_capacity(CompressionType::iter().count());
// Sort compression types by their string representation
let mut sorted_compression_types: Vec<CompressionType> = CompressionType::iter().collect();
sorted_compression_types.sort_by_key(|ct| ct.to_string());
for compression_type in sorted_compression_types {
let (binary, compress, decompress, supported) =
match get_compression_engine(compression_type.clone()) {
Ok(engine) => {
let supp = engine.is_supported();
if supp && engine.is_internal() {
(
"<INTERNAL>".to_string(),
"".to_string(),
"".to_string(),
supp,
)
} else if supp {
let (b, c, d) = engine.get_status_info();
(b, c, d, supp)
} else {
(
"<UNSUPPORTED>".to_string(),
"".to_string(),
"".to_string(),
supp,
)
}
}
Err(_) => (
"<UNSUPPORTED>".to_string(),
"".to_string(),
"".to_string(),
false,
),
};
let is_enabled = enabled_compression_type
.as_ref()
.is_some_and(|ct| *ct == compression_type);
compression_info.push(CompressionInfo {
compression_type: compression_type.to_string(),
found: supported,
default: is_enabled,
binary,
compress,
decompress,
});
}
let mut meta_plugins_map =
std::collections::HashMap::with_capacity(MetaPluginType::iter().count());
let mut enabled_meta_plugins_vec = Vec::new();
// Sort meta plugin types by their string representation to avoid creating plugins just for sorting
let mut sorted_meta_plugins: Vec<MetaPluginType> = MetaPluginType::iter().collect();
sorted_meta_plugins.sort_by_key(|meta_plugin_type| meta_plugin_type.to_string());
for meta_plugin_type in sorted_meta_plugins {
log::debug!("STATUS: Processing meta plugin type: {meta_plugin_type:?}");
let meta_plugin =
match crate::meta_plugin::get_meta_plugin(meta_plugin_type.clone(), None, None) {
Ok(p) => p,
Err(e) => {
log::warn!(
"STATUS: Skipping unregistered meta plugin {meta_plugin_type:?}: {e}"
);
continue;
}
};
// Get meta name first to avoid borrowing issues
log::debug!("STATUS: Getting meta name...");
let meta_name = meta_plugin.meta_type().to_string();
log::debug!("STATUS: Got meta name: {meta_name}");
// Check if this plugin is enabled
let is_enabled = enabled_meta_plugins.contains(&meta_plugin_type);
if is_enabled {
enabled_meta_plugins_vec.push(meta_name.clone());
}
// Create a display of outputs for status - use configured outputs if available, otherwise defaults
let outputs_display = if meta_plugin.outputs().is_empty() {
// No configured outputs, use defaults
let mut default_outputs = std::collections::HashMap::new();
for output_name in meta_plugin.default_outputs() {
default_outputs.insert(output_name.clone(), serde_yaml::Value::String(output_name));
}
default_outputs
} else {
// Use configured outputs
meta_plugin.outputs().clone()
};
// Get options
let options = meta_plugin.options().clone();
meta_plugins_map.insert(
meta_name.clone(),
MetaPluginInfo {
meta_name,
outputs: outputs_display,
options,
},
);
}
// Populate filter plugin info from the global registry
let filter_plugins_map = crate::services::filter_service::get_available_filter_plugins()?;
let filter_plugins_info: Vec<FilterPluginInfo> = filter_plugins_map
.into_iter()
.map(|(name, creator)| {
let plugin = creator();
FilterPluginInfo {
name: name.clone(),
options: plugin.options(),
description: format!("{name} filter plugin"),
}
})
.collect();
Ok(StatusInfo {
paths: path_info,
compression: compression_info,
meta_plugins: meta_plugins_map,
enabled_meta_plugins: enabled_meta_plugins_vec,
filter_plugins: filter_plugins_info,
configured_meta_plugins: None,
})
}

View File

@@ -1,121 +0,0 @@
use anyhow::Result;
use std::io;
use std::io::{Read, Write};
use std::path::PathBuf;
use strum::IntoEnumIterator;
use log::*;
use lazy_static::lazy_static;
extern crate enum_map;
use enum_map::enum_map;
use enum_map::{Enum, EnumMap};
pub mod gzip;
pub mod lz4;
pub mod none;
pub mod program;
use crate::compression_engine::gzip::CompressionEngineGZip;
use crate::compression_engine::lz4::CompressionEngineLZ4;
use crate::compression_engine::none::CompressionEngineNone;
use crate::compression_engine::program::CompressionEngineProgram;
#[derive(Debug, Eq, PartialEq, Clone, strum::EnumIter, strum::Display, strum::EnumString, Enum)]
#[strum(ascii_case_insensitive)]
pub enum CompressionType {
LZ4,
GZip,
BZip2,
XZ,
ZStd,
None,
}
pub trait CompressionEngine {
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read>>;
fn create(&self, file_path: PathBuf) -> Result<Box<dyn Write>>;
fn is_supported(&self) -> bool {
true
}
fn copy(&self, file_path: PathBuf, writer: &mut dyn Write) -> Result<()> {
let mut reader = self.open(file_path)?;
io::copy(&mut reader, writer)?;
writer.flush()?;
Ok(())
}
fn cat(&self, file_path: PathBuf) -> Result<()> {
let mut stdout = io::stdout().lock();
self.copy(file_path, &mut stdout)
}
fn size(&self, file_path: PathBuf) -> Result<usize> {
let mut reader = self.open(file_path)?;
let mut buffer = [0; libc::BUFSIZ as usize];
let mut size: usize = 0;
loop {
let n = reader.read(&mut buffer[..libc::BUFSIZ as usize])?;
if n == 0 {
debug!("COMPRESSION: EOF");
break;
}
size += n;
}
Ok(size)
}
}
lazy_static! {
pub static ref COMPRESSION_PROGRAMS: EnumMap<CompressionType, Option<CompressionEngineProgram>> = enum_map! {
CompressionType::LZ4 => None,
CompressionType::GZip => None,
CompressionType::BZip2 => {
let program = CompressionEngineProgram::new("bzip2", vec!["-qcf"], vec!["-dcf"]);
if program.supported { Some(program) } else { None }
},
CompressionType::XZ => {
let program = CompressionEngineProgram::new("xz", vec!["-qcf"], vec!["-dcf"]);
if program.supported { Some(program) } else { None }
},
CompressionType::ZStd => {
let program = CompressionEngineProgram::new("zstd", vec!["-qcf"], vec!["-dcf"]);
if program.supported { Some(program) } else { None }
},
CompressionType::None => None
};
}
pub fn get_compression_engine(
compression_type: CompressionType,
) -> Result<Box<dyn CompressionEngine>> {
match compression_type {
CompressionType::LZ4 => Ok(Box::new(CompressionEngineLZ4::new())),
CompressionType::GZip => Ok(Box::new(CompressionEngineGZip::new())),
CompressionType::None => Ok(Box::new(CompressionEngineNone::new())),
compression_type => Ok(Box::new(
COMPRESSION_PROGRAMS[compression_type.clone()]
.clone()
.unwrap(),
)),
}
}
pub fn default_compression_type() -> CompressionType {
let mut default = CompressionType::None;
for compression_type in CompressionType::iter() {
let compression_engine =
get_compression_engine(compression_type.clone()).expect("Missing engine");
if compression_engine.is_supported() {
default = compression_type;
break;
}
}
default
}

View File

@@ -1,31 +1,48 @@
#[cfg(feature = "gzip")]
use anyhow::Result;
#[cfg(feature = "gzip")]
use log::*;
#[cfg(feature = "gzip")]
use std::fs::File;
#[cfg(feature = "gzip")]
use std::io;
#[cfg(feature = "gzip")]
use std::io::{Read, Write};
#[cfg(feature = "gzip")]
use std::path::PathBuf;
#[cfg(feature = "gzip")]
use flate2::Compression;
#[cfg(feature = "gzip")]
use flate2::read::GzDecoder;
#[cfg(feature = "gzip")]
use flate2::write::GzEncoder;
#[cfg(feature = "gzip")]
use crate::compression_engine::CompressionEngine;
#[cfg(feature = "gzip")]
#[derive(Debug, Eq, PartialEq, Clone, Default)]
pub struct CompressionEngineGZip {}
#[cfg(feature = "gzip")]
impl CompressionEngineGZip {
pub fn new() -> CompressionEngineGZip {
CompressionEngineGZip {}
}
}
#[cfg(feature = "gzip")]
impl CompressionEngine for CompressionEngineGZip {
fn is_supported(&self) -> bool {
true
}
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read>> {
fn get_status_info(&self) -> (String, String, String) {
("<INTERNAL>".to_string(), "".to_string(), "".to_string())
}
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read + Send>> {
debug!("COMPRESSION: Opening {:?} using {:?}", file_path, *self);
let file = File::open(file_path)?;
@@ -33,19 +50,26 @@ impl CompressionEngine for CompressionEngineGZip {
}
fn create(&self, file_path: PathBuf) -> Result<Box<dyn Write>> {
debug!("COMPRESSION: Writting to {:?} using {:?}", file_path, *self);
debug!("COMPRESSION: Writing to {:?} using {:?}", file_path, *self);
let file = File::create(file_path)?;
let gzip_write = GzEncoder::new(file, Compression::default());
Ok(Box::new(AutoFinishGzEncoder::new(gzip_write)))
}
fn clone_box(&self) -> Box<dyn CompressionEngine> {
Box::new(self.clone())
}
}
#[cfg(feature = "gzip")]
#[derive(Debug)]
pub struct AutoFinishGzEncoder<W: Write> {
encoder: Option<GzEncoder<W>>,
}
#[cfg(feature = "gzip")]
impl<W: Write> AutoFinishGzEncoder<W> {
fn new(gz_encoder: GzEncoder<W>) -> AutoFinishGzEncoder<W> {
AutoFinishGzEncoder {
@@ -54,21 +78,37 @@ impl<W: Write> AutoFinishGzEncoder<W> {
}
}
#[cfg(feature = "gzip")]
impl<W: Write> Drop for AutoFinishGzEncoder<W> {
fn drop(&mut self) {
if let Some(encoder) = self.encoder.take() {
debug!("COMPRESSION: Finishing");
let _ = encoder.finish();
if let Err(e) = encoder.finish() {
warn!("Failed to finish GZip encoder: {e}");
}
}
}
}
#[cfg(feature = "gzip")]
impl<W: Write> Write for AutoFinishGzEncoder<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.encoder.as_mut().unwrap().write(buf)
match self.encoder.as_mut() {
Some(encoder) => encoder.write(buf),
None => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"encoder already finished",
)),
}
}
fn flush(&mut self) -> io::Result<()> {
self.encoder.as_mut().unwrap().flush()
match self.encoder.as_mut() {
Some(encoder) => encoder.flush(),
None => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"encoder already finished",
)),
}
}
}

View File

@@ -1,25 +1,36 @@
#[cfg(feature = "lz4")]
use anyhow::Result;
#[cfg(feature = "lz4")]
use log::*;
#[cfg(feature = "lz4")]
use std::io::Write;
#[cfg(feature = "lz4")]
use lz4_flex::frame::{FrameDecoder, FrameEncoder};
#[cfg(feature = "lz4")]
use std::fs::File;
#[cfg(feature = "lz4")]
use std::io::Read;
#[cfg(feature = "lz4")]
use std::path::PathBuf;
#[cfg(feature = "lz4")]
use crate::compression_engine::CompressionEngine;
#[cfg(feature = "lz4")]
#[derive(Debug, Eq, PartialEq, Clone, Default)]
pub struct CompressionEngineLZ4 {}
#[cfg(feature = "lz4")]
impl CompressionEngineLZ4 {
pub fn new() -> CompressionEngineLZ4 {
CompressionEngineLZ4 {}
}
}
#[cfg(feature = "lz4")]
impl CompressionEngine for CompressionEngineLZ4 {
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read>> {
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read + Send>> {
debug!("COMPRESSION: Opening {:?} using {:?}", file_path, *self);
let file = File::open(file_path)?;
@@ -27,11 +38,15 @@ impl CompressionEngine for CompressionEngineLZ4 {
}
fn create(&self, file_path: PathBuf) -> Result<Box<dyn Write>> {
debug!("COMPRESSION: Writting to {:?} using {:?}", file_path, *self);
debug!("COMPRESSION: Writing to {:?} using {:?}", file_path, *self);
let file = File::create(file_path)?;
let lz4_write = FrameEncoder::new(file).auto_finish();
Ok(Box::new(lz4_write))
}
fn clone_box(&self) -> Box<dyn CompressionEngine> {
Box::new(self.clone())
}
}

View File

@@ -0,0 +1,251 @@
use anyhow::{Result, anyhow};
use std::io;
use std::io::{Read, Write};
use std::path::PathBuf;
use strum::IntoEnumIterator;
use strum::{Display, EnumIter, EnumString};
use log::*;
extern crate enum_map;
use enum_map::enum_map;
use enum_map::{Enum, EnumMap};
pub mod gzip;
pub mod lz4;
pub mod program;
pub mod raw;
pub mod zstd;
use crate::compression_engine::program::CompressionEngineProgram;
/// Enum representing different compression types supported by the system.
///
/// This enum defines all supported compression formats that can be used for
/// storing and retrieving compressed items. Each variant corresponds to a
/// specific compression algorithm or no compression.
///
/// # Examples
///
/// ```ignore
/// assert_eq!(CompressionType::GZip.to_string(), "gzip");
/// ```
#[derive(Debug, Eq, PartialEq, Clone, EnumIter, Display, EnumString, enum_map::Enum)]
#[strum(ascii_case_insensitive)]
pub enum CompressionType {
#[strum(serialize = "lz4")]
LZ4,
#[strum(serialize = "gzip")]
GZip,
#[strum(serialize = "bzip2")]
BZip2,
#[strum(serialize = "xz")]
XZ,
#[strum(serialize = "zstd")]
ZStd,
#[strum(to_string = "raw", serialize = "raw", serialize = "none")]
Raw,
}
/// Trait defining the interface for compression engines.
///
/// This trait provides a unified API for different compression implementations.
/// Implementors handle reading from and writing to compressed files, as well as
/// utility operations like copying decompressed content or calculating sizes.
///
/// # Errors
///
/// Methods may return `anyhow::Error` for I/O failures, unsupported formats,
/// or invalid file paths.
///
/// # Examples
///
/// ```ignore
/// // Example usage would depend on a concrete implementation
/// use keep::compression_engine::CompressionEngine;
/// let engine = /* some engine */;
/// let reader = engine.open("file.gz".into()).unwrap();
/// ```
pub trait CompressionEngine: Send + Sync {
/// Opens a compressed file for reading.
///
/// Creates a reader that transparently decompresses the file contents as they are read.
///
/// # Arguments
///
/// * `file_path` - Path to the compressed file.
///
/// # Returns
///
/// * `Result<Box<dyn Read + Send>>` - A boxed reader that decompresses the file on read,
/// or an error if the file cannot be opened or is invalid.
///
/// # Errors
///
/// Returns an error if the file does not exist, is not a valid compressed file,
/// or if decompression fails.
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read + Send>>;
/// Creates a new compressed file for writing.
///
/// Creates a writer that transparently compresses data as it is written.
///
/// # Arguments
///
/// * `file_path` - Path where the compressed file will be created.
///
/// # Returns
///
/// * `Result<Box<dyn Write>>` - A boxed writer that compresses data on write,
/// or an error if the file cannot be created.
///
/// # Errors
///
/// Returns an error if the path is invalid or if there are permission issues.
fn create(&self, file_path: PathBuf) -> Result<Box<dyn Write>>;
/// Checks if this compression engine is supported on the current system.
///
/// Some compression types may require external programs or features to be enabled.
///
/// # Returns
///
/// * `bool` - True if supported, false otherwise.
fn is_supported(&self) -> bool {
true
}
/// Checks if this compression engine is internal (built-in) or external (program-based).
///
/// Internal engines use Rust implementations without external dependencies.
/// External engines rely on system programs.
///
/// # Returns
///
/// * `bool` - True if internal, false if external.
fn is_internal(&self) -> bool {
true
}
/// Returns status information for this compression engine.
///
/// For internal engines, returns ("<INTERNAL>", "", "").
/// For external program engines, returns (program_binary, compress_args, decompress_args).
///
/// # Returns
///
/// A tuple of (binary, compress_command, decompress_command).
fn get_status_info(&self) -> (String, String, String) {
("<INTERNAL>".to_string(), "".to_string(), "".to_string())
}
/// Copies decompressed content from a file to a writer.
///
/// Reads the compressed file and writes the decompressed content to the provided writer.
///
/// # Arguments
///
/// * `file_path` - Path to the compressed file.
/// * `writer` - Writer to receive decompressed content.
///
/// # Returns
///
/// * `Result<()>` - Success if the copy completes, or an error.
///
/// # Errors
///
/// Propagates errors from opening the file or copying data.
fn copy(&self, file_path: PathBuf, writer: &mut dyn Write) -> Result<()> {
let mut reader = self.open(file_path)?;
io::copy(&mut reader, writer)?;
writer.flush()?;
Ok(())
}
/// Clones this compression engine into a new boxed instance.
///
/// Required for dynamic trait object cloning.
///
/// # Returns
///
/// A new `Box<dyn CompressionEngine>` clone of this engine.
fn clone_box(&self) -> Box<dyn CompressionEngine>;
}
impl Clone for Box<dyn CompressionEngine> {
fn clone(&self) -> Self {
self.as_ref().clone_box()
}
}
fn init_compression_engines() -> EnumMap<CompressionType, Box<dyn CompressionEngine>> {
#[allow(unused_mut)]
let mut em: EnumMap<CompressionType, Box<dyn CompressionEngine>> = enum_map! {
CompressionType::LZ4 => Box::new(crate::compression_engine::program::CompressionEngineProgram::new(
"lz4",
vec!["-c"],
vec!["-d", "-c"]
)) as Box<dyn CompressionEngine>,
CompressionType::GZip => Box::new(crate::compression_engine::program::CompressionEngineProgram::new(
"gzip",
vec!["-c"],
vec!["-d", "-c"]
)) as Box<dyn CompressionEngine>,
CompressionType::BZip2 => Box::new(crate::compression_engine::program::CompressionEngineProgram::new(
"bzip2",
vec!["-c"],
vec!["-d", "-c"]
)) as Box<dyn CompressionEngine>,
CompressionType::XZ => Box::new(crate::compression_engine::program::CompressionEngineProgram::new(
"xz",
vec!["-c"],
vec!["-d", "-c"]
)) as Box<dyn CompressionEngine>,
CompressionType::ZStd => Box::new(crate::compression_engine::program::CompressionEngineProgram::new(
"zstd",
vec!["-c"],
vec!["-d", "-c"]
)) as Box<dyn CompressionEngine>,
CompressionType::Raw => Box::new(crate::compression_engine::raw::CompressionEngineRaw::new()) as Box<dyn CompressionEngine>
};
#[cfg(feature = "gzip")]
{
em[CompressionType::GZip] =
Box::new(crate::compression_engine::gzip::CompressionEngineGZip::new())
as Box<dyn CompressionEngine>;
}
#[cfg(feature = "lz4")]
{
em[CompressionType::LZ4] =
Box::new(crate::compression_engine::lz4::CompressionEngineLZ4::new())
as Box<dyn CompressionEngine>;
}
#[cfg(feature = "zstd")]
{
em[CompressionType::ZStd] =
Box::new(crate::compression_engine::zstd::CompressionEngineZstd::new())
as Box<dyn CompressionEngine>;
}
em
}
static COMPRESSION_ENGINES: std::sync::LazyLock<
EnumMap<CompressionType, Box<dyn CompressionEngine>>,
> = std::sync::LazyLock::new(init_compression_engines);
pub fn default_compression_type() -> CompressionType {
CompressionType::LZ4
}
pub fn get_compression_engine(ct: CompressionType) -> Result<Box<dyn CompressionEngine>> {
let engine = &COMPRESSION_ENGINES[ct.clone()];
if engine.is_supported() {
Ok(engine.clone())
} else {
Err(anyhow!("Compression engine for {ct} is not supported",))
}
}

View File

@@ -1,33 +0,0 @@
use anyhow::Result;
use log::*;
use std::fs::File;
use std::io::{Read, Write};
use std::path::PathBuf;
use crate::compression_engine::CompressionEngine;
#[derive(Debug, Eq, PartialEq, Clone, Default)]
pub struct CompressionEngineNone {}
impl CompressionEngineNone {
pub fn new() -> CompressionEngineNone {
CompressionEngineNone {}
}
}
impl CompressionEngine for CompressionEngineNone {
fn size(&self, file_path: PathBuf) -> Result<usize> {
let item_file_metadata = file_path.metadata()?;
Ok(item_file_metadata.len() as usize)
}
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read>> {
debug!("COMPRESSION: Opening {:?} using {:?}", file_path, *self);
Ok(Box::new(File::open(file_path)?))
}
fn create(&self, file_path: PathBuf) -> Result<Box<dyn Write>> {
debug!("COMPRESSION: Writting to {:?} using {:?}", file_path, *self);
Ok(Box::new(File::create(file_path)?))
}
}

View File

@@ -1,12 +1,10 @@
use anyhow::{Context, Result, anyhow};
use log::*;
use std::env;
use std::fs;
use std::fs::File;
use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use which::which;
use crate::compression_engine::CompressionEngine;
@@ -17,7 +15,13 @@ pub struct ProgramReader {
impl Read for ProgramReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.stdout.as_mut().unwrap().read(buf)
match self.stdout.as_mut() {
Some(stdout) => stdout.read(buf),
None => Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"stdout already taken",
)),
}
}
}
@@ -35,11 +39,23 @@ pub struct ProgramWriter {
impl Write for ProgramWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.stdin.as_mut().unwrap().write(buf)
match self.stdin.as_mut() {
Some(stdin) => stdin.write(buf),
None => Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"stdin already taken",
)),
}
}
fn flush(&mut self) -> std::io::Result<()> {
self.stdin.as_mut().unwrap().flush()
match self.stdin.as_mut() {
Some(stdin) => stdin.flush(),
None => Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"stdin already taken",
)),
}
}
}
@@ -66,11 +82,12 @@ impl CompressionEngineProgram {
compress: Vec<&str>,
decompress: Vec<&str>,
) -> CompressionEngineProgram {
let program_path = get_program_path(program);
let program_path = which(program);
let supported = program_path.is_ok();
CompressionEngineProgram {
program: program_path.unwrap_or(program.to_string()),
program: program_path
.map_or_else(|_| program.to_string(), |p| p.to_string_lossy().to_string()),
compress: compress.iter().map(|s| s.to_string()).collect(),
decompress: decompress.iter().map(|s| s.to_string()).collect(),
supported,
@@ -78,39 +95,30 @@ impl CompressionEngineProgram {
}
}
fn get_program_path(program: &str) -> Result<String> {
debug!("COMPRESSION: Looking for executable: {}", program);
if let Ok(path) = env::var("PATH") {
for p in path.split(':') {
let p_str = format!("{}/{}", p, program);
let stat = fs::metadata(p_str.clone());
if let Ok(stat) = stat {
let md = stat;
let permissions = md.permissions();
if md.is_file() && permissions.mode() & 0o111 != 0 {
return Ok(p_str);
}
}
}
}
Err(anyhow!("Unable to find binary {} in PATH", program))
}
impl CompressionEngine for CompressionEngineProgram {
fn is_supported(&self) -> bool {
self.supported
}
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read>> {
debug!("COMPRESSION: Opening {:?} using {:?}", file_path, *self);
fn is_internal(&self) -> bool {
false
}
fn get_status_info(&self) -> (String, String, String) {
(
self.program.clone(),
self.compress.join(" "),
self.decompress.join(" "),
)
}
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read + Send>> {
debug!("COMPRESSION: Opening {file_path:?} using {self:?}");
let program = self.program.clone();
let args = self.decompress.clone();
debug!(
"COMPRESSION: Executing command: {:?} {:?} reading from {:?}",
program, args, file_path
);
debug!("COMPRESSION: Executing command: {program:?} {args:?} reading from {file_path:?}");
let file = File::open(file_path).context("Unable to open file for reading")?;
@@ -125,9 +133,10 @@ impl CompressionEngine for CompressionEngineProgram {
args
))?;
let stdout = process.stdout.take().ok_or_else(|| {
anyhow!("Failed to capture stdout from child process")
})?;
let stdout = process
.stdout
.take()
.ok_or_else(|| anyhow!("Failed to capture stdout from child process"))?;
Ok(Box::new(ProgramReader {
process,
@@ -136,15 +145,12 @@ impl CompressionEngine for CompressionEngineProgram {
}
fn create(&self, file_path: PathBuf) -> Result<Box<dyn Write>> {
debug!("COMPRESSION: Writing to {:?} using {:?}", file_path, *self);
debug!("COMPRESSION: Writing to {file_path:?} using {self:?}");
let program = self.program.clone();
let args = self.compress.clone();
debug!(
"COMPRESSION: Executing command: {:?} {:?} writing to {:?}",
program, args, file_path
);
debug!("COMPRESSION: Executing command: {program:?} {args:?} writing to {file_path:?}");
let file = File::create(file_path).context("Unable to open file for writing")?;
@@ -159,13 +165,18 @@ impl CompressionEngine for CompressionEngineProgram {
args
))?;
let stdin = process.stdin.take().ok_or_else(|| {
anyhow!("Failed to capture stdin from child process")
})?;
let stdin = process
.stdin
.take()
.ok_or_else(|| anyhow!("Failed to capture stdin from child process"))?;
Ok(Box::new(ProgramWriter {
process,
stdin: Some(stdin),
}))
}
fn clone_box(&self) -> Box<dyn CompressionEngine> {
Box::new(self.clone())
}
}

View File

@@ -0,0 +1,40 @@
use anyhow::Result;
use log::*;
use std::fs::File;
use std::io::{Read, Write};
use std::path::PathBuf;
use crate::compression_engine::CompressionEngine;
#[derive(Debug, Eq, PartialEq, Clone, Default)]
pub struct CompressionEngineRaw {}
impl CompressionEngineRaw {
pub fn new() -> CompressionEngineRaw {
CompressionEngineRaw {}
}
}
impl CompressionEngine for CompressionEngineRaw {
fn is_supported(&self) -> bool {
true
}
fn get_status_info(&self) -> (String, String, String) {
("<INTERNAL>".to_string(), "".to_string(), "".to_string())
}
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read + Send>> {
debug!("COMPRESSION: Opening {:?} using {:?}", file_path, *self);
Ok(Box::new(File::open(file_path)?))
}
fn create(&self, file_path: PathBuf) -> Result<Box<dyn Write>> {
debug!("COMPRESSION: Writing to {:?} using {:?}", file_path, *self);
Ok(Box::new(File::create(file_path)?))
}
fn clone_box(&self) -> Box<dyn CompressionEngine> {
Box::new(self.clone())
}
}

View File

@@ -0,0 +1,54 @@
#[cfg(feature = "zstd")]
use anyhow::Result;
#[cfg(feature = "zstd")]
use log::*;
#[cfg(feature = "zstd")]
use std::io::Write;
#[cfg(feature = "zstd")]
use std::fs::File;
#[cfg(feature = "zstd")]
use std::io::Read;
#[cfg(feature = "zstd")]
use std::path::PathBuf;
#[cfg(feature = "zstd")]
use zstd::stream::read::Decoder;
#[cfg(feature = "zstd")]
use zstd::stream::write::Encoder;
#[cfg(feature = "zstd")]
use crate::compression_engine::CompressionEngine;
#[cfg(feature = "zstd")]
#[derive(Debug, Eq, PartialEq, Clone, Default)]
pub struct CompressionEngineZstd {}
#[cfg(feature = "zstd")]
impl CompressionEngineZstd {
pub fn new() -> CompressionEngineZstd {
CompressionEngineZstd {}
}
}
#[cfg(feature = "zstd")]
impl CompressionEngine for CompressionEngineZstd {
fn open(&self, file_path: PathBuf) -> Result<Box<dyn Read + Send>> {
debug!("COMPRESSION: Opening {:?} using {:?}", file_path, *self);
let file = File::open(file_path)?;
Ok(Box::new(Decoder::new(file)?))
}
fn create(&self, file_path: PathBuf) -> Result<Box<dyn Write>> {
debug!("COMPRESSION: Writing to {:?} using {:?}", file_path, *self);
let file = File::create(file_path)?;
let zstd_write = Encoder::new(file, 3)?.auto_finish();
Ok(Box::new(zstd_write))
}
fn clone_box(&self) -> Box<dyn CompressionEngine> {
Box::new(self.clone())
}
}

842
src/config.rs Normal file
View File

@@ -0,0 +1,842 @@
use crate::args::Args;
use anyhow::{Context, Result};
use dirs;
use log::{debug, error};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ColumnAlignment {
#[default]
Left,
Right,
Center,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ContentArrangement {
#[default]
Dynamic,
DynamicFullWidth,
Disabled,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TableStyle {
Ascii,
Utf8,
Utf8Full,
#[default]
Nothing,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TableColor {
Black,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
Gray,
DarkRed,
DarkGreen,
DarkYellow,
DarkBlue,
DarkMagenta,
DarkCyan,
Rgb(u8, u8, u8),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TableAttribute {
Bold,
Dim,
Italic,
Underlined,
SlowBlink,
RapidBlink,
Reverse,
Hidden,
CrossedOut,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TableConfig {
#[serde(default)]
pub style: TableStyle,
#[serde(default)]
pub modifiers: Vec<String>,
#[serde(default)]
pub content_arrangement: ContentArrangement,
#[serde(default)]
pub truncation_indicator: String,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct ColumnConfig {
pub name: String,
pub label: String,
#[serde(default)]
pub align: ColumnAlignment,
#[serde(default)]
pub max_len: Option<String>,
#[serde(default)]
pub fg_color: Option<TableColor>,
#[serde(default)]
pub bg_color: Option<TableColor>,
#[serde(default)]
pub attributes: Vec<TableAttribute>,
#[serde(default)]
pub padding: Option<(u16, u16)>,
}
impl<'de> serde::Deserialize<'de> for ColumnConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Helper {
name: String,
label: Option<String>,
#[serde(default)]
align: ColumnAlignment,
#[serde(default)]
max_len: Option<String>,
#[serde(default)]
fg_color: Option<TableColor>,
#[serde(default)]
bg_color: Option<TableColor>,
#[serde(default)]
attributes: Vec<TableAttribute>,
#[serde(default)]
padding: Option<(u16, u16)>,
}
let helper = Helper::deserialize(deserializer)?;
let label = helper.label.unwrap_or_else(|| helper.name.clone());
Ok(ColumnConfig {
name: helper.name,
label,
align: helper.align,
max_len: helper.max_len,
fg_color: helper.fg_color,
bg_color: helper.bg_color,
attributes: helper.attributes,
padding: helper.padding,
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ServerConfig {
pub address: Option<String>,
pub port: Option<u16>,
pub username: Option<String>,
pub password_file: Option<PathBuf>,
pub password: Option<String>,
pub password_hash: Option<String>,
pub jwt_secret: Option<String>,
pub jwt_secret_file: Option<PathBuf>,
pub cert_file: Option<PathBuf>,
pub key_file: Option<PathBuf>,
pub cors_origin: Option<String>,
pub max_body_size: Option<u64>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CompressionPluginConfig {
pub name: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ClientConfig {
pub url: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
pub jwt: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct MetaPluginConfig {
pub name: String,
#[serde(default)]
#[cfg_attr(feature = "server", schema(value_type = Object))]
pub options: std::collections::HashMap<String, serde_yaml::Value>,
#[serde(default)]
#[cfg_attr(feature = "server", schema(value_type = Object))]
pub outputs: std::collections::HashMap<String, String>,
}
/// Unified settings that merges config file and CLI arguments
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Settings {
#[serde(default)]
pub dir: PathBuf,
#[serde(default)]
pub list_format: Vec<ColumnConfig>,
#[serde(default)]
pub table_config: TableConfig,
#[serde(default)]
pub human_readable: bool,
#[serde(default)]
pub ids_only: bool,
pub output_format: Option<String>,
#[serde(default)]
pub quiet: bool,
#[serde(default)]
pub force: bool,
pub server: Option<ServerConfig>,
pub compression_plugin: Option<CompressionPluginConfig>,
pub meta_plugins: Option<Vec<MetaPluginConfig>>,
pub client: Option<ClientConfig>,
// Non-serializable fields populated from CLI args
#[serde(skip)]
pub client_url: Option<String>,
#[serde(skip)]
pub client_username: Option<String>,
#[serde(skip)]
pub client_password: Option<String>,
#[serde(skip)]
pub client_jwt: Option<String>,
// Metadata key-value pairs from --meta CLI flag
#[serde(skip)]
pub meta: Vec<(String, Option<String>)>,
// Export filename format template (--export-filename-format)
#[serde(skip)]
pub export_filename_format: String,
// Export name for {name} variable (--export-name)
#[serde(skip)]
pub export_name: Option<String>,
// Import data file path (--import-data-file)
#[serde(skip)]
pub import_data_file: Option<std::path::PathBuf>,
}
impl Settings {
/// Create unified settings from config and args with proper priority
pub fn new(args: &Args, default_dir: PathBuf) -> Result<Self> {
debug!("CONFIG: Creating settings with default dir: {default_dir:?}");
let config_path = if let Some(config_path) = &args.options.config {
config_path.clone()
} else if let Ok(env_config) = std::env::var("KEEP_CONFIG") {
PathBuf::from(env_config)
} else {
let default_path = dirs::config_dir()
.map(|mut p| {
p.push("keep");
p.push("config.yml");
p
})
.unwrap_or_else(|| PathBuf::from("~/.config/keep/config.yml"));
debug!("CONFIG: Using default config path: {default_path:?}");
default_path
};
debug!("CONFIG: Using config path: {config_path:?}");
let mut config_builder = config::Config::builder();
// Load config file if it exists
if config_path.exists() {
debug!("CONFIG: Loading config file: {config_path:?}");
config_builder =
config_builder.add_source(config::File::from(config_path.clone()).required(false));
} else {
debug!("CONFIG: Config file does not exist: {config_path:?}");
}
// Add environment variables
debug!("CONFIG: Adding environment variables");
let env_source = config::Environment::with_prefix("KEEP")
.separator("__")
.ignore_empty(true);
config_builder = config_builder.add_source(env_source);
// Override with CLI args
if let Some(dir) = &args.options.dir {
debug!("CONFIG: Overriding dir with CLI arg: {dir:?}");
config_builder = config_builder.set_override(
"dir",
dir.to_str()
.ok_or_else(|| anyhow::anyhow!("non-UTF-8 directory path"))?,
)?;
}
if args.options.human_readable {
config_builder = config_builder.set_override("human_readable", true)?;
}
if args.options.ids_only {
config_builder = config_builder.set_override("ids_only", true)?;
}
if let Some(output_format) = &args.options.output_format {
config_builder =
config_builder.set_override("output_format", output_format.as_str())?;
}
if args.options.verbose > 0 {
config_builder = config_builder.set_override("verbose", args.options.verbose)?;
}
if args.options.quiet {
config_builder = config_builder.set_override("quiet", true)?;
}
if args.options.force {
config_builder = config_builder.set_override("force", true)?;
}
#[cfg(feature = "server")]
if let Some(server_password) = &args.options.server_password {
config_builder =
config_builder.set_override("server.password", server_password.as_str())?;
}
#[cfg(feature = "server")]
if let Some(server_password_hash) = &args.options.server_password_hash {
config_builder = config_builder
.set_override("server.password_hash", server_password_hash.as_str())?;
}
#[cfg(feature = "server")]
if let Some(server_username) = &args.options.server_username {
config_builder =
config_builder.set_override("server.username", server_username.as_str())?;
}
#[cfg(feature = "server")]
if let Some(server_address) = &args.mode.server_address {
config_builder =
config_builder.set_override("server.address", server_address.as_str())?;
}
#[cfg(feature = "server")]
if let Some(server_port) = args.mode.server_port {
config_builder = config_builder.set_override("server.port", server_port)?;
}
#[cfg(feature = "server")]
if let Some(server_cert) = &args.mode.server_cert {
config_builder = config_builder
.set_override("server.cert_file", server_cert.to_string_lossy().as_ref())?;
}
#[cfg(feature = "server")]
if let Some(server_key) = &args.mode.server_key {
config_builder = config_builder
.set_override("server.key_file", server_key.to_string_lossy().as_ref())?;
}
#[cfg(feature = "server")]
if let Some(max_body_size) = args.options.server_max_body_size {
config_builder = config_builder.set_override("server.max_body_size", max_body_size)?;
}
if let Some(compression) = &args.item.compression {
config_builder =
config_builder.set_override("compression_plugin.name", compression.as_str())?;
}
// Build MetaPluginConfig entries from --meta-plugin args (name[:json])
// These are handled after config deserialization (see below).
let config = config_builder.build()?;
debug!("CONFIG: Built config, attempting to deserialize");
match config.try_deserialize::<Settings>() {
Ok(mut settings) => {
debug!("CONFIG: Successfully deserialized settings: {settings:?}");
// Set defaults for list_format if not provided
if settings.list_format.is_empty() {
debug!("CONFIG: Setting default list_format");
settings.list_format = vec![
ColumnConfig {
name: "id".to_string(),
label: "Item".to_string(),
align: ColumnAlignment::Right,
max_len: None,
fg_color: None,
bg_color: None,
attributes: Vec::new(),
padding: None,
},
ColumnConfig {
name: "time".to_string(),
label: "Time".to_string(),
align: ColumnAlignment::Right,
max_len: None,
fg_color: None,
bg_color: None,
attributes: Vec::new(),
padding: None,
},
ColumnConfig {
name: "size".to_string(),
label: "Size".to_string(),
align: ColumnAlignment::Right,
max_len: None,
fg_color: None,
bg_color: None,
attributes: Vec::new(),
padding: None,
},
ColumnConfig {
name: "meta:text_line_count".to_string(),
label: "Lines".to_string(),
align: ColumnAlignment::Right,
max_len: None,
fg_color: None,
bg_color: None,
attributes: Vec::new(),
padding: None,
},
ColumnConfig {
name: "tags".to_string(),
label: "Tags".to_string(),
align: ColumnAlignment::Left,
max_len: None,
fg_color: None,
bg_color: None,
attributes: Vec::new(),
padding: None,
},
ColumnConfig {
name: "meta:hostname_short".to_string(),
label: "Host".to_string(),
align: ColumnAlignment::Left,
max_len: None,
fg_color: None,
bg_color: None,
attributes: Vec::new(),
padding: None,
},
ColumnConfig {
name: "meta:command".to_string(),
label: "Command".to_string(),
align: ColumnAlignment::Left,
max_len: None,
fg_color: None,
bg_color: None,
attributes: Vec::new(),
padding: None,
},
];
}
// Set default meta_plugins to include 'env' if not provided
if settings.meta_plugins.is_none() {
debug!("CONFIG: Setting default meta_plugins to include 'env'");
settings.meta_plugins = Some(vec![MetaPluginConfig {
name: "env".to_string(),
options: std::collections::HashMap::new(),
outputs: std::collections::HashMap::new(),
}]);
}
// Override meta_plugins from --meta-plugin CLI args
if !args.item.meta_plugins.is_empty() {
debug!("CONFIG: Overriding meta_plugins from --meta-plugin CLI args");
let cli_plugins: Vec<MetaPluginConfig> = args
.item
.meta_plugins
.iter()
.map(|arg| {
let mut options = std::collections::HashMap::new();
let mut outputs = std::collections::HashMap::new();
if let Some(serde_json::Value::Object(obj)) = &arg.options {
// Extract options and outputs from JSON value
if let Some(serde_json::Value::Object(opts_obj)) =
obj.get("options")
{
for (k, v) in opts_obj {
let yaml_str = serde_json::to_string(v).unwrap_or_default();
let yaml_val: serde_yaml::Value =
serde_yaml::from_str(&yaml_str)
.unwrap_or(serde_yaml::Value::Null);
options.insert(k.clone(), yaml_val);
}
}
if let Some(serde_json::Value::Object(outs_obj)) =
obj.get("outputs")
{
for (k, v) in outs_obj {
let val_str = match v {
serde_json::Value::String(s) => s.clone(),
_ => v.to_string(),
};
outputs.insert(k.clone(), val_str);
}
}
}
MetaPluginConfig {
name: arg.name.clone(),
options,
outputs,
}
})
.collect();
settings.meta_plugins = Some(cli_plugins);
}
// Override list_format from --list-format CLI arg
if args.options.list_format
!= "id,time,size,meta:text_line_count,tags,meta:hostname_short,meta:command"
{
debug!("CONFIG: Overriding list_format from --list-format CLI arg");
settings.list_format = Settings::parse_list_format(&args.options.list_format);
}
// Set dir to default if not provided or is empty
if settings.dir == PathBuf::new() {
debug!("CONFIG: Setting default dir: {default_dir:?}");
settings.dir = default_dir;
}
// Populate client settings from CLI args and config
#[cfg(feature = "client")]
{
settings.client_url = args
.options
.client_url
.clone()
.or_else(|| settings.client.as_ref().and_then(|c| c.url.clone()));
settings.client_username = args
.options
.client_username
.clone()
.or_else(|| settings.client.as_ref().and_then(|c| c.username.clone()));
settings.client_password = args
.options
.client_password
.clone()
.or_else(|| settings.client.as_ref().and_then(|c| c.password.clone()));
settings.client_jwt = args
.options
.client_jwt
.clone()
.or_else(|| settings.client.as_ref().and_then(|c| c.jwt.clone()));
}
// Parse --meta key=value and bare key arguments
settings.meta = args
.item
.meta
.iter()
.map(|s| {
if let Some((key, value)) = s.split_once('=') {
(key.to_string(), Some(value.to_string()))
} else {
(s.to_string(), None)
}
})
.collect();
// Set export filename format from CLI args
settings.export_filename_format = args.item.export_filename_format.clone();
settings.export_name = args.item.export_name.clone();
settings.import_data_file = args.item.import_data_file.clone();
// Expand ~ in all path fields
settings.dir = Settings::expand_tilde(&settings.dir);
settings.import_data_file = settings
.import_data_file
.as_ref()
.map(|p| Settings::expand_tilde(p));
if let Some(ref mut server) = settings.server {
server.password_file = server
.password_file
.as_ref()
.map(|p| Settings::expand_tilde(p));
server.jwt_secret_file = server
.jwt_secret_file
.as_ref()
.map(|p| Settings::expand_tilde(p));
server.cert_file = server.cert_file.as_ref().map(|p| Settings::expand_tilde(p));
server.key_file = server.key_file.as_ref().map(|p| Settings::expand_tilde(p));
}
debug!("CONFIG: Final settings: {settings:?}");
Ok(settings)
}
Err(e) => {
error!("CONFIG: Failed to deserialize settings: {e}");
Err(e.into())
}
}
}
pub fn default_dir() -> anyhow::Result<PathBuf> {
let mut path =
dirs::data_dir().ok_or_else(|| anyhow::anyhow!("No data directory found"))?;
path.push("keep");
if !path.exists() {
std::fs::create_dir_all(&path)?;
}
Ok(path)
}
/// Expand a leading `~` in a path to the user's home directory.
///
/// Returns the path unchanged if it doesn't start with `~` or if the
/// home directory cannot be determined.
fn expand_tilde(path: &Path) -> PathBuf {
let path_str = path.to_string_lossy();
if let Some(rest) = path_str.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
} else if path_str == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
path.to_path_buf()
}
/// Get server password from password_file or directly from config if configured
pub fn get_server_password(&self) -> Result<Option<String>> {
if let Some(server) = &self.server {
// First check for password_file
if let Some(password_file) = &server.password_file {
debug!("CONFIG: Reading password from file: {password_file:?}");
let password = fs::read(password_file)
.with_context(|| format!("Failed to read password file: {password_file:?}"))?;
let end = password.len().min(4096);
let password = String::from_utf8_lossy(&password[..end]).trim().to_string();
return Ok(Some(password));
}
// Fall back to direct password field
if let Some(password) = &server.password {
debug!("CONFIG: Using password from config");
return Ok(Some(password.clone()));
}
}
Ok(None)
}
// Helper methods to access configuration values
pub fn server_password(&self) -> Option<String> {
self.get_server_password().ok().flatten()
}
pub fn server_password_hash(&self) -> Option<String> {
self.server.as_ref().and_then(|s| s.password_hash.clone())
}
pub fn server_username(&self) -> Option<String> {
self.server.as_ref().and_then(|s| s.username.clone())
}
/// Get JWT secret from jwt_secret_file or directly from config if configured
pub fn get_server_jwt_secret(&self) -> Result<Option<String>> {
if let Some(server) = &self.server {
// First check for jwt_secret_file
if let Some(jwt_secret_file) = &server.jwt_secret_file {
debug!("CONFIG: Reading JWT secret from file: {jwt_secret_file:?}");
let secret = fs::read(jwt_secret_file).with_context(|| {
format!("Failed to read JWT secret file: {jwt_secret_file:?}")
})?;
let end = secret.len().min(4096);
let secret = String::from_utf8_lossy(&secret[..end]).trim().to_string();
return Ok(Some(secret));
}
// Fall back to direct jwt_secret field
if let Some(secret) = &server.jwt_secret {
debug!("CONFIG: Using JWT secret from config");
return Ok(Some(secret.clone()));
}
}
Ok(None)
}
pub fn server_jwt_secret(&self) -> Option<String> {
self.get_server_jwt_secret().ok().flatten()
}
pub fn server_address(&self) -> Option<String> {
self.server.as_ref().and_then(|s| s.address.clone())
}
pub fn server_port(&self) -> Option<u16> {
self.server.as_ref().and_then(|s| s.port)
}
pub fn server_cert_file(&self) -> Option<PathBuf> {
self.server.as_ref().and_then(|s| s.cert_file.clone())
}
pub fn server_key_file(&self) -> Option<PathBuf> {
self.server.as_ref().and_then(|s| s.key_file.clone())
}
pub fn server_cors_origin(&self) -> Option<String> {
self.server.as_ref().and_then(|s| s.cors_origin.clone())
}
pub fn compression(&self) -> Option<String> {
self.compression_plugin.as_ref().map(|c| c.name.clone())
}
pub fn meta_plugins_names(&self) -> Vec<String> {
self.meta_plugins
.as_ref()
.map(|plugins| plugins.iter().map(|p| p.name.clone()).collect())
.unwrap_or_default()
}
/// Returns the metadata filter as a HashMap.
///
/// Converts the `meta` field (list of key-value pairs from CLI --meta flags)
/// into a `HashMap<String, Option<String>>` suitable for filtering.
pub fn meta_filter(&self) -> std::collections::HashMap<String, Option<String>> {
self.meta.iter().cloned().collect()
}
/// Validates the configuration against plugin schemas.
///
/// Checks that:
/// - All configured meta plugin names are valid and registered
/// - Required options are present for each meta plugin
/// - Compression plugin name (if set) is a valid compression type
///
/// Returns a list of warning strings. An empty list means the config is valid.
pub fn validate_config(&self) -> Vec<String> {
use crate::common::schema::gather_meta_plugin_schemas;
use crate::compression_engine::CompressionType;
use strum::IntoEnumIterator;
let mut warnings = Vec::new();
// Validate compression plugin
if let Some(ref comp) = self.compression_plugin {
let valid_types: Vec<String> =
CompressionType::iter().map(|ct| ct.to_string()).collect();
if !valid_types.contains(&comp.name) {
warnings.push(format!(
"Unknown compression_plugin.name: '{}'. Valid types: {}",
comp.name,
valid_types.join(", ")
));
}
}
// Validate meta plugins
if let Some(ref plugins) = self.meta_plugins {
let schemas = gather_meta_plugin_schemas();
let schema_map: std::collections::HashMap<&str, &crate::common::schema::PluginSchema> =
schemas.iter().map(|s| (s.name.as_str(), s)).collect();
for plugin in plugins {
match schema_map.get(plugin.name.as_str()) {
Some(schema) => {
// Check required options
for opt in &schema.options {
if opt.required && !plugin.options.contains_key(&opt.name) {
warnings.push(format!(
"Meta plugin '{}': missing required option '{}'",
plugin.name, opt.name
));
}
}
}
None => {
warnings.push(format!(
"Unknown meta plugin: '{}'. Available: {}",
plugin.name,
schema_map.keys().copied().collect::<Vec<_>>().join(", ")
));
}
}
}
}
warnings
}
/// Parse a comma-separated column list string into Vec<ColumnConfig>.
///
/// Maps known column names to their default labels and alignment.
/// For unknown names (including meta:* columns), uses the name as its own label.
fn parse_list_format(input: &str) -> Vec<ColumnConfig> {
input
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|name| {
let (label, align) = match name {
"id" => ("Item", ColumnAlignment::Right),
"time" => ("Time", ColumnAlignment::Right),
"size" => ("Size", ColumnAlignment::Right),
"meta:text_line_count" => ("Lines", ColumnAlignment::Right),
"meta:token_count" => ("Tokens", ColumnAlignment::Right),
"tags" => ("Tags", ColumnAlignment::Left),
"meta:hostname_short" => ("Host", ColumnAlignment::Left),
"meta:hostname" => ("Host", ColumnAlignment::Left),
"meta:command" => ("Command", ColumnAlignment::Left),
"compression" => ("Compression", ColumnAlignment::Left),
other if other.starts_with("meta:") => {
let sub = other.strip_prefix("meta:").unwrap_or(other);
(sub, ColumnAlignment::Left)
}
other => (other, ColumnAlignment::Left),
};
ColumnConfig {
name: name.to_string(),
label: label.to_string(),
align,
..Default::default()
}
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_expand_tilde_with_slash() {
let home = dirs::home_dir().unwrap();
let result = Settings::expand_tilde(Path::new("~/foo/bar"));
assert_eq!(result, home.join("foo/bar"));
}
#[test]
fn test_expand_tilde_bare() {
let home = dirs::home_dir().unwrap();
let result = Settings::expand_tilde(Path::new("~"));
assert_eq!(result, home);
}
#[test]
fn test_expand_tilde_absolute() {
let result = Settings::expand_tilde(Path::new("/etc/keep"));
assert_eq!(result, PathBuf::from("/etc/keep"));
}
#[test]
fn test_expand_tilde_relative() {
let result = Settings::expand_tilde(Path::new("foo/bar"));
assert_eq!(result, PathBuf::from("foo/bar"));
}
}

1379
src/db.rs

File diff suppressed because it is too large Load Diff

167
src/export_tar.rs Normal file
View File

@@ -0,0 +1,167 @@
use anyhow::{Context, Result, anyhow};
use log::debug;
use std::collections::HashSet;
use std::fs;
use std::io::{Read, Seek, Write};
use std::path::Path;
use tar::{Builder, Header};
use crate::filter_plugin::FilterChain;
use crate::modes::common::ExportMeta;
use crate::services::item_service::ItemService;
use crate::services::types::ItemWithMeta;
/// Compute the intersection of all items' tag sets.
///
/// Returns sorted tags that are present on ALL items.
pub fn common_tags(items: &[ItemWithMeta]) -> Vec<String> {
if items.is_empty() {
return Vec::new();
}
let mut common: HashSet<String> = items[0].tag_names().into_iter().collect();
for item in items.iter().skip(1) {
let item_tags: HashSet<String> = item.tag_names().into_iter().collect();
common = common.intersection(&item_tags).cloned().collect();
}
let mut result: Vec<String> = common.into_iter().collect();
result.sort();
result
}
/// Resolve the export name from the CLI arg or compute default from common tags.
///
/// If `arg` is Some, uses that value directly.
/// Otherwise, computes `export_<common-tags>` or just `export` if no common tags.
pub fn export_name(arg: &Option<String>, items: &[ItemWithMeta]) -> String {
if let Some(name) = arg {
return name.clone();
}
let tags = common_tags(items);
if tags.is_empty() {
"export".to_string()
} else {
format!("export_{}", tags.join("_"))
}
}
/// Write items to a tar archive, streaming data without loading files into memory.
///
/// The archive contains `<dir_name>/<id>.data.<compression>` and
/// `<dir_name>/<id>.meta.yml` for each item.
///
/// # Arguments
///
/// * `writer` - The output writer (e.g., a File).
/// * `dir_name` - Top-level directory name inside the tar.
/// * `items` - Items to export.
/// * `data_path` - Path to the data storage directory.
/// * `filter_chain` - Optional filter chain for transforming content on export.
/// * `item_service` - Item service for streaming content.
/// * `conn` - Database connection for filter chain operations.
pub fn write_export_tar<W: Write>(
writer: W,
dir_name: &str,
items: &[ItemWithMeta],
data_path: &Path,
filter_chain: Option<&FilterChain>,
item_service: &ItemService,
conn: &rusqlite::Connection,
) -> Result<()> {
let mut builder = Builder::new(writer);
for item_with_meta in items {
let item_id = item_with_meta.item.id.context("Item missing ID")?;
let compression = &item_with_meta.item.compression;
let item_tags = item_with_meta.tag_names();
let meta_map = item_with_meta.meta_as_map();
let data_path_entry = format!("{dir_name}/{item_id}.data.{compression}");
let meta_path_entry = format!("{dir_name}/{item_id}.meta.yml");
// Meta entry (small, in-memory is fine)
let export_meta = ExportMeta {
ts: item_with_meta.item.ts,
compression: compression.clone(),
uncompressed_size: item_with_meta.item.uncompressed_size,
tags: item_tags,
metadata: meta_map,
};
let meta_yaml = serde_yaml::to_string(&export_meta)?;
let meta_bytes = meta_yaml.into_bytes();
let meta_len = meta_bytes.len() as u64;
let mut meta_header = Header::new_gnu();
meta_header.set_size(meta_len);
meta_header.set_mode(0o644);
meta_header.set_path(&meta_path_entry)?;
meta_header.set_cksum();
builder
.append(&meta_header, meta_bytes.as_slice())
.with_context(|| format!("Cannot write meta entry for item {item_id}"))?;
debug!("EXPORT_TAR: Wrote meta entry {meta_path_entry}");
// Data entry
let mut item_file_path = data_path.to_path_buf();
item_file_path.push(item_id.to_string());
if let Some(chain) = filter_chain {
// Filtered export: spool through filter chain to a temp file,
// then stream the temp file into the tar with known size.
let (mut reader, _, _) = item_service.get_item_content_info_streaming_with_chain(
conn,
item_id,
Some(chain),
)?;
let mut tmp = tempfile::NamedTempFile::new()
.context("Cannot create temp file for filtered export")?;
let mut buf = [0u8; crate::common::PIPESIZE];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
tmp.write_all(&buf[..n])?;
}
tmp.flush()?;
let total_size = tmp.as_file().metadata()?.len();
tmp.rewind()?;
let mut data_header = Header::new_gnu();
data_header.set_size(total_size);
data_header.set_mode(0o644);
data_header.set_path(&data_path_entry)?;
data_header.set_cksum();
builder
.append(&data_header, &mut tmp)
.with_context(|| format!("Cannot write data entry for item {item_id}"))?;
debug!("EXPORT_TAR: Wrote filtered data entry {data_path_entry} ({total_size} bytes)");
} else {
// Unfiltered export: stream raw compressed file
let file = fs::File::open(&item_file_path)
.with_context(|| format!("Cannot open data file: {}", item_file_path.display()))?;
let file_size = file.metadata()?.len();
let mut data_header = Header::new_gnu();
data_header.set_size(file_size);
data_header.set_mode(0o644);
data_header.set_path(&data_path_entry)?;
data_header.set_cksum();
builder
.append(&data_header, file)
.with_context(|| format!("Cannot write data entry for item {item_id}"))?;
debug!("EXPORT_TAR: Wrote data entry {data_path_entry} ({file_size} bytes)");
}
}
builder.finish().context("Cannot finalize tar archive")?;
debug!("EXPORT_TAR: Archive finalized");
Ok(())
}

222
src/filter_plugin/exec.rs Normal file
View File

@@ -0,0 +1,222 @@
use super::{FilterOption, FilterPlugin};
use log::*;
use std::io::{Read, Result, Write};
use std::process::{Child, Command, Stdio};
use which::which;
/// A filter that executes an external program and pipes input through it.
///
/// This filter spawns an external command, pipes the input stream to its stdin,
/// and writes the stdout to the output stream. Supports async-like behavior via
/// threads for concurrent I/O. Requires the program to be available on PATH.
#[derive(Debug, Clone)]
pub struct ExecFilter {
program: String,
args: Vec<String>,
supported: bool,
split_whitespace: bool,
child_process: Option<Child>,
stdin_writer: Option<std::process::ChildStdin>,
stdout_reader: Option<std::process::ChildStdout>,
}
impl ExecFilter {
/// Creates a new `ExecFilter` for the specified program and arguments.
///
/// Checks if the program is available using `which` and stores the resolved path.
///
/// # Arguments
///
/// * `program` - The name or path of the program to execute.
/// * `args` - A slice of string slices representing the arguments to pass to the program.
/// * `split_whitespace` - Whether to split arguments on whitespace when parsing (unused in this context).
///
/// # Returns
///
/// A new `ExecFilter` instance.
///
/// # Examples
///
/// ```
/// use keep::filter_plugin::exec::ExecFilter;
///
/// let filter = ExecFilter::new("grep", vec!["-i", "error"], false);
/// assert!(filter.supported);
/// ```
pub fn new(program: &str, args: Vec<&str>, split_whitespace: bool) -> ExecFilter {
let program_path = which(program);
let supported = program_path.is_ok();
ExecFilter {
program: program_path
.map_or_else(|| program.to_string(), |p| p.to_string_lossy().to_string()),
args: args.iter().map(|s| s.to_string()).collect(),
supported,
split_whitespace,
child_process: None,
stdin_writer: None,
stdout_reader: None,
}
}
}
impl FilterPlugin for ExecFilter {
/// Filters the input by piping it through the external program and writing the output.
///
/// Spawns the process with piped I/O, uses threads for concurrent input/output
/// copying, and waits for completion. Errors if the program isn't found or fails.
///
/// # Arguments
///
/// * `reader` - A boxed mutable reference to the input reader providing the data stream to pipe to the program.
/// * `writer` - A boxed mutable reference to the output writer where the program's output is sent.
///
/// # Returns
///
/// Returns `Ok(())` on success, or an `io::Error` if process spawning, piping, or execution fails.
///
/// # Errors
///
/// * NotFound - Program not available.
/// * Other - Spawn, I/O, or wait failures.
///
/// # Examples
///
/// ```
/// use keep::filter_plugin::exec::ExecFilter;
/// use std::io::{Read, Write};
///
/// let mut filter = ExecFilter::new("cat", vec![], false);
/// // In filter context:
/// filter.filter(Box::new(&mut input), Box::new(&mut output)).unwrap();
/// ```
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
if !self.supported {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Program '{}' not found", self.program),
));
}
debug!(
"FILTER_EXEC: Executing command: {} {:?}",
self.program, self.args
);
// Read all input first
let mut input_data = Vec::new();
std::io::copy(reader, &mut input_data)?;
let mut child = Command::new(&self.program)
.args(&self.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to spawn process '{}': {}", self.program, e),
)
})?;
let mut stdin = child.stdin.take().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::Other,
"Failed to capture stdin from child process",
)
})?;
// Write input to child stdin
stdin.write_all(&input_data)?;
drop(stdin); // Close stdin to signal EOF
let mut stdout = child.stdout.take().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::Other,
"Failed to capture stdout from child process",
)
})?;
// Copy stdout to writer
std::io::copy(&mut stdout, writer)?;
// Wait for the child process to finish
let output = child.wait_with_output().map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to wait on child process: {}", e),
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
warn!("FILTER_EXEC: Process stderr: {}", stderr);
}
return Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Process exited with error: {:?}", output.status),
));
}
debug!("FILTER_EXEC: Process completed successfully");
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(ExecFilter {
program: self.program.clone(),
args: self.args.clone(),
supported: self.supported,
split_whitespace: self.split_whitespace,
child_process: None,
stdin_writer: None,
stdout_reader: None,
})
}
/// Returns the configuration options for this filter.
///
/// Defines "command" as required and "split_whitespace" as optional.
///
/// # Returns
///
/// A vector of `FilterOption` describing the filter's configurable parameters.
fn options(&self) -> Vec<FilterOption> {
vec![
FilterOption {
name: "command".to_string(),
default: None,
required: true,
},
FilterOption {
name: "split_whitespace".to_string(),
default: Some(serde_json::Value::Bool(true)),
required: false,
},
]
}
fn description(&self) -> &str {
"Pipe input through an external command"
}
}
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_exec_filter() {
crate::services::filter_service::register_filter_plugin("exec", || {
// Create a dummy instance - actual creation happens in create method
Box::new(ExecFilter {
program: String::new(),
args: Vec::new(),
supported: false,
split_whitespace: true,
child_process: None,
stdin_writer: None,
stdout_reader: None,
})
})
.expect("Failed to register exec filter");
}

120
src/filter_plugin/grep.rs Normal file
View File

@@ -0,0 +1,120 @@
use super::{FilterOption, FilterPlugin};
use regex::Regex;
use std::io::{BufRead, Read, Result, Write};
/// A filter that matches lines against a regular expression pattern.
///
/// Outputs only lines that match the given regex. Uses BufRead for line-by-line processing
/// and preserves original line endings.
///
/// # Fields
///
/// * `regex` - Compiled regex for matching.
#[derive(Debug, Clone)]
pub struct GrepFilter {
regex: Regex,
}
/// Creates a new `GrepFilter` with the specified regex pattern.
///
/// Compiles the pattern using regex crate.
///
/// # Arguments
///
/// * `pattern` - The regular expression pattern (string) used to match lines.
///
/// # Returns
///
/// `Ok(Self)` on success.
///
/// # Errors
///
/// Returns `Err(io::Error::InvalidInput)` if pattern compilation fails (invalid regex).
///
/// # Examples
///
/// ```
/// # use keep::filter_plugin::GrepFilter;
/// let filter = GrepFilter::new("error|warn".to_string())?;
/// # Ok::<(), std::io::Error>(())
/// ```
impl GrepFilter {
pub fn new(pattern: String) -> Result<Self> {
let regex = Regex::new(&pattern)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
Ok(Self { regex })
}
}
/// Filters the input by writing only lines that match the regex pattern.
///
/// Reads lines from the input and writes matching lines to the output, preserving newlines.
/// Uses BufReader for efficient line iteration.
///
/// # Arguments
///
/// * `reader` - Mutable reference to the input data stream.
/// * `writer` - Mutable reference to the output writer where matching lines are sent.
///
/// # Returns
///
/// `Ok(())` on success.
///
/// # Errors
///
/// Propagates `io::Error` from BufRead lines() or writeln! (e.g., read/write failures, UTF-8 issues).
///
/// # Examples
///
/// ```
/// # use std::io::{Read, Write, Cursor};
/// # use keep::filter_plugin::{FilterPlugin, GrepFilter};
/// # let mut filter = GrepFilter::new("error".to_string())?;
/// let mut input: &mut dyn Read = &mut Cursor::new(b"error: something failed\nok: all good\n");
/// let mut output = Vec::new();
/// filter.filter(&mut input, &mut output)?;
/// # Ok::<(), std::io::Error>(())
/// ```
impl FilterPlugin for GrepFilter {
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
let mut buf_reader = std::io::BufReader::new(reader);
for line in buf_reader.by_ref().lines() {
let line = line?;
if self.regex.is_match(&line) {
writeln!(writer, "{line}")?;
}
}
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self {
regex: self.regex.clone(),
})
}
/// Returns the configuration options for this filter.
///
/// The only option is the required "pattern" for the regex.
///
/// # Returns
///
/// A vector containing one `FilterOption` for "pattern" (required, no default).
///
/// # Examples
///
/// ```
/// # use keep::filter_plugin::{FilterPlugin, GrepFilter};
/// let filter = GrepFilter::new("test".to_string()).unwrap();
/// let opts = filter.options();
/// assert_eq!(opts.len(), 1);
/// assert!(opts[0].required);
/// ```
fn options(&self) -> Vec<FilterOption> {
crate::filter_plugin::pattern_option()
}
fn description(&self) -> &str {
"Filter lines matching a regex pattern"
}
}

228
src/filter_plugin/head.rs Normal file
View File

@@ -0,0 +1,228 @@
use super::{FilterOption, FilterPlugin};
use crate::common::PIPESIZE;
use crate::services::filter_service::register_filter_plugin;
use std::io::{BufRead, Read, Result, Write};
#[derive(Clone)]
pub struct HeadBytesFilter {
remaining: usize,
}
/// A filter that reads the first N bytes from the input stream.
///
/// Limits the output to the initial bytes specified in the configuration.
/// Useful for previewing file contents without reading everything.
///
/// # Fields
///
/// * `remaining` - Number of bytes left to read before stopping.
impl HeadBytesFilter {
/// Creates a new `HeadBytesFilter` that will read up to the specified number of bytes.
///
/// # Arguments
///
/// * `count` - The maximum number of bytes to read from the input.
///
/// # Returns
///
/// A new instance configured to read at most `count` bytes.
///
/// # Examples
///
/// ```
/// # use keep::filter_plugin::HeadBytesFilter;
/// let filter = HeadBytesFilter::new(1024);
/// ```
pub fn new(count: usize) -> Self {
Self { remaining: count }
}
}
/// Filters input by reading only the first N bytes and writing them to the output.
///
/// Reads from the input in chunks until the byte limit is reached or EOF, then writes
/// the collected bytes to the output. Stops early if the limit is zero.
///
/// # Arguments
///
/// * `reader` - Mutable reference to the input data stream.
/// * `writer` - Mutable reference to the output stream.
///
/// # Returns
///
/// * `Result<()>` - Success if filtering completes, or I/O error.
///
/// # Errors
///
/// * `io::Error` from reading or writing operations.
///
/// # Examples
///
/// ```
/// # use std::io::{Read, Write, Cursor};
/// # use keep::filter_plugin::{FilterPlugin, HeadBytesFilter};
/// # let mut filter = HeadBytesFilter::new(5);
/// let mut input: &mut dyn Read = &mut Cursor::new(b"Hello World");
/// let mut output = Vec::new();
/// filter.filter(&mut input, &mut output)?;
/// assert_eq!(output, b"Hello");
/// # Ok::<(), std::io::Error>(())
/// ```
impl FilterPlugin for HeadBytesFilter {
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
if self.remaining == 0 {
return Ok(());
}
let mut buffer = vec![0; PIPESIZE];
while self.remaining > 0 {
let to_read = std::cmp::min(self.remaining, PIPESIZE);
let bytes_read = reader.read(&mut buffer[..to_read])?;
if bytes_read == 0 {
break;
}
writer.write_all(&buffer[..bytes_read])?;
self.remaining -= bytes_read;
}
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self {
remaining: self.remaining,
})
}
/// Returns the configuration options for this filter.
///
/// Defines the "count" parameter as required with no default.
///
/// # Returns
///
/// Vector of `FilterOption` describing parameters.
///
/// # Examples
///
/// ```
/// # use keep::filter_plugin::{FilterPlugin, HeadBytesFilter};
/// let filter = HeadBytesFilter::new(100);
/// let opts = filter.options();
/// assert_eq!(opts.len(), 1);
/// assert_eq!(opts[0].name, "count");
/// assert!(opts[0].required);
/// ```
fn options(&self) -> Vec<FilterOption> {
crate::filter_plugin::count_option()
}
fn description(&self) -> &str {
"Read the first N bytes"
}
}
#[derive(Clone)]
pub struct HeadLinesFilter {
remaining: usize,
}
/// A filter that reads the first N lines from the input stream.
///
/// Limits output to the initial lines specified, writing each full line to output.
/// Handles line endings properly using buffered reading.
///
/// # Fields
///
/// * `remaining` - Number of lines left to read before stopping.
impl HeadLinesFilter {
/// Creates a new `HeadLinesFilter` that will read up to the specified number of lines.
///
/// # Arguments
///
/// * `count` - The maximum number of lines to read from the input.
///
/// # Returns
///
/// A new instance configured to read at most `count` lines.
///
/// # Examples
///
/// ```
/// # use keep::filter_plugin::HeadLinesFilter;
/// let filter = HeadLinesFilter::new(3);
/// ```
pub fn new(count: usize) -> Self {
Self { remaining: count }
}
}
/// Filters input by reading only the first N lines and writing them to the output.
///
/// Uses buffered line reading to process input line-by-line until the limit or EOF.
///
/// # Arguments
///
/// * `reader` - Mutable reference to the input data stream.
/// * `writer` - Mutable reference to the output stream.
///
/// # Returns
///
/// * `Result<()>` - Success if filtering completes, or I/O error.
///
/// # Errors
///
/// * `io::Error` from line reading or writing operations.
///
/// # Examples
///
/// ```
/// # use std::io::{Read, Write, Cursor};
/// # use keep::filter_plugin::{FilterPlugin, HeadLinesFilter};
/// # let mut filter = HeadLinesFilter::new(2);
/// let mut input: &mut dyn Read = &mut Cursor::new(b"Line1\nLine2\nLine3\n");
/// let mut output = Vec::new();
/// filter.filter(&mut input, &mut output)?;
/// assert_eq!(output, b"Line1\nLine2\n");
/// # Ok::<(), std::io::Error>(())
/// ```
impl FilterPlugin for HeadLinesFilter {
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
if self.remaining == 0 {
return Ok(());
}
let mut buf_reader = std::io::BufReader::new(reader);
for line in buf_reader.by_ref().lines() {
let line = line?;
writeln!(writer, "{line}")?;
self.remaining -= 1;
if self.remaining == 0 {
break;
}
}
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self {
remaining: self.remaining,
})
}
/// Returns the configuration options for this filter.
fn options(&self) -> Vec<FilterOption> {
crate::filter_plugin::count_option()
}
fn description(&self) -> &str {
"Read the first N lines"
}
}
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_head_filters() {
register_filter_plugin("head_bytes", || Box::new(HeadBytesFilter::new(0)))
.expect("Failed to register head_bytes filter");
register_filter_plugin("head_lines", || Box::new(HeadLinesFilter::new(0)))
.expect("Failed to register head_lines filter");
}

827
src/filter_plugin/mod.rs Normal file
View File

@@ -0,0 +1,827 @@
use std::io::{Read, Result, Write};
use std::str::FromStr;
use strum::EnumString;
#[cfg(feature = "filter_grep")]
pub mod grep;
/// Filter plugin module for processing input streams.
///
/// This module defines the `FilterPlugin` trait and `FilterChain` for chaining filters,
/// along with parsing utilities for filter strings. Filters can process data like head/tail,
/// grep, etc.
///
/// # Usage
///
/// Parse a filter string and apply to a reader:
///
/// ```
/// # use std::io::{Read, Write};
/// # use keep::filter_plugin::parse_filter_string;
/// let mut chain = parse_filter_string("head_lines(10)|tail_lines(5)")?;
/// # let mut reader: &mut dyn Read = &mut std::io::empty();
/// # let mut writer: Vec<u8> = Vec::new();
/// # chain.filter(&mut reader, &mut writer)?;
/// # Ok::<(), std::io::Error>(())
/// ```
pub mod head;
pub mod skip;
pub mod strip_ansi;
pub mod tail;
#[cfg(feature = "meta_tokens")]
pub mod tokens;
pub mod utils;
use std::collections::HashMap;
#[cfg(feature = "filter_grep")]
pub use grep::GrepFilter;
pub use head::{HeadBytesFilter, HeadLinesFilter};
pub use skip::{SkipBytesFilter, SkipLinesFilter};
pub use strip_ansi::StripAnsiFilter;
pub use tail::{TailBytesFilter, TailLinesFilter};
/// Represents an option for a filter plugin.
///
/// Defines a configurable parameter for filters, with name, default, and required flag.
///
/// # Fields
///
/// * `name` - Option name.
/// * `default` - Optional default value.
/// * `required` - If true, must be provided.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
pub struct FilterOption {
pub name: String,
#[cfg_attr(feature = "server", schema(value_type = Option<Object>))]
pub default: Option<serde_json::Value>,
pub required: bool,
}
/// Trait for filter plugins that process input streams.
///
/// Implement this trait to create a filter that reads from an input stream and writes filtered output.
///
/// # Required Methods
///
/// * `filter` - Process the stream.
/// * `clone_box` - For cloning dynamic instances.
/// * `options` - Describe configurable options.
///
/// # Examples
///
/// ```
/// # use std::io::{Read, Write, Result};
/// # use keep::filter_plugin::{FilterPlugin, FilterOption};
/// struct MyFilter;
/// impl FilterPlugin for MyFilter {
/// fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
/// // Implementation
/// Ok(())
/// }
/// fn clone_box(&self) -> Box<dyn FilterPlugin> {
/// Box::new(MyFilter)
/// }
/// fn options(&self) -> Vec<FilterOption> {
/// vec![]
/// }
/// }
/// ```
pub trait FilterPlugin: Send {
/// Processes the input stream and writes the filtered output.
///
/// This method reads from the input reader and applies filtering logic,
/// writing the processed data to the output writer.
///
/// # Arguments
///
/// * `reader` - A mutable reference to the input reader providing the data to filter.
/// * `writer` - A mutable reference to the output writer where the processed data is written.
///
/// # Returns
///
/// A `Result` indicating success (`Ok(())`) or failure with an `io::Error`.
///
/// # Examples
///
/// ```
/// # use std::io::{Read, Write, Result};
/// # use keep::filter_plugin::{FilterPlugin, FilterOption};
/// struct MyFilter;
/// impl FilterPlugin for MyFilter {
/// fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
/// let mut buf = [0; 1024];
/// loop {
/// let n = reader.read(&mut buf)?;
/// if n == 0 { break; }
/// writer.write_all(&buf[0..n])?;
/// }
/// Ok(())
/// }
/// fn clone_box(&self) -> Box<dyn FilterPlugin> {
/// Box::new(Self)
/// }
/// fn options(&self) -> Vec<FilterOption> {
/// vec![]
/// }
/// }
/// ```
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
let _ = std::io::copy(reader, writer)?;
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin>;
/// Returns the configuration options for this plugin.
///
/// Describes the configurable parameters, including names, defaults, and required flags.
///
/// # Returns
///
/// A vector of `FilterOption` structs describing the plugin's options.
///
/// # Examples
///
/// ```
/// # use keep::filter_plugin::FilterOption;
/// fn example_options() -> Vec<FilterOption> {
/// vec![
/// FilterOption {
/// name: "pattern".to_string(),
/// default: None,
/// required: true,
/// },
/// ]
/// }
/// ```
fn options(&self) -> Vec<FilterOption>;
/// Returns a human-readable description of this filter.
///
/// # Returns
///
/// A description string (empty by default).
fn description(&self) -> &str {
""
}
}
pub fn count_option() -> Vec<FilterOption> {
vec![FilterOption {
name: "count".to_string(),
default: None,
required: true,
}]
}
pub fn pattern_option() -> Vec<FilterOption> {
vec![FilterOption {
name: "pattern".to_string(),
default: None,
required: true,
}]
}
/// Enum representing the different types of filters.
///
/// Used for parsing and instantiating specific filter plugins.
///
/// # Variants
///
/// * `HeadBytes` - Head by bytes.
/// * `HeadLines` - Head by lines.
/// * ... etc.
#[derive(Debug, EnumString, strum::VariantNames, strum::Display)]
#[strum(serialize_all = "snake_case")]
pub enum FilterType {
HeadBytes,
HeadLines,
TailBytes,
TailLines,
SkipBytes,
SkipLines,
#[cfg(feature = "filter_grep")]
Grep,
StripAnsi,
#[cfg(feature = "meta_tokens")]
HeadTokens,
#[cfg(feature = "meta_tokens")]
SkipTokens,
#[cfg(feature = "meta_tokens")]
TailTokens,
}
/// Maximum buffer size (256 MB) for filter chain intermediate results.
/// Prevents OOM on large files by rejecting inputs that exceed this limit.
const MAX_FILTER_BUFFER_SIZE: usize = 256 * 1024 * 1024;
struct BoundedVecWriter {
data: Vec<u8>,
limit: usize,
}
impl BoundedVecWriter {
fn new(limit: usize) -> Self {
Self {
data: Vec::new(),
limit,
}
}
fn into_inner(self) -> Vec<u8> {
self.data
}
}
impl std::io::Write for BoundedVecWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.data.len() + buf.len() > self.limit {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"Input size exceeds maximum filter buffer size ({} bytes)",
MAX_FILTER_BUFFER_SIZE
),
));
}
self.data.write_all(buf)?;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
/// A chain of filter plugins applied sequentially.
///
/// Chains multiple filters, applying them in order to the input stream.
///
/// # Fields
///
/// * `plugins` - Vector of boxed filter plugins.
pub struct FilterChain {
plugins: Vec<Box<dyn FilterPlugin>>,
}
/// A chain of filter plugins applied sequentially.
///
/// Chains multiple filters, applying them in order to the input stream.
///
/// # Fields
///
/// * `plugins` - Vector of boxed filter plugins.
///
/// # Examples
///
/// ```
/// # use std::io::{Read, Write, Result};
/// # use keep::filter_plugin::{FilterChain, HeadLinesFilter};
/// let mut chain = FilterChain::new();
/// chain.add_plugin(Box::new(HeadLinesFilter::new(10)));
/// # let mut reader: &mut dyn Read = &mut std::io::empty();
/// # let mut writer: Vec<u8> = Vec::new();
/// # chain.filter(&mut reader, &mut writer)?;
/// # Ok::<(), std::io::Error>(())
/// ```
impl Clone for FilterChain {
/// Clones this filter chain.
///
/// # Returns
///
/// A new `FilterChain` with cloned plugins.
fn clone(&self) -> Self {
let mut plugins = Vec::with_capacity(self.plugins.len());
for plugin in &self.plugins {
plugins.push(plugin.clone_box());
}
FilterChain { plugins }
}
}
impl Clone for Box<dyn FilterPlugin> {
fn clone(&self) -> Self {
self.clone_box()
}
}
#[macro_export]
macro_rules! filter_clone_box {
($self:expr) => {
Box::new($self.clone())
};
($self:expr, $field:ident) => {
Box::new(Self { $field: $self.$field.clone() })
};
($self:expr, $field:ident, $($rest:ident),+) => {
Box::new(Self {
$field: $self.$field.clone(),
$($rest: $self.$rest.clone()),+
})
};
}
impl Default for FilterChain {
fn default() -> Self {
Self::new()
}
}
impl FilterChain {
/// Creates a new empty filter chain.
///
/// # Returns
///
/// A new `FilterChain` with no plugins.
///
/// # Examples
///
/// ```
/// # use keep::filter_plugin::FilterChain;
/// let chain = FilterChain::new();
/// // Chain starts empty
/// ```
pub fn new() -> Self {
Self {
plugins: Vec::new(),
}
}
/// Adds a plugin to the chain.
///
/// Plugins are applied in the order they are added.
///
/// # Arguments
///
/// * `plugin` - The boxed filter plugin to add to the chain.
///
/// # Examples
///
/// ```
/// # use keep::filter_plugin::FilterChain;
/// let mut chain = FilterChain::new();
/// ```
pub fn add_plugin(&mut self, plugin: Box<dyn FilterPlugin>) {
self.plugins.push(plugin);
}
/// Applies the filter chain to the input and writes to the output.
///
/// If no plugins are present, data is copied directly from reader to writer.
/// For multiple plugins, intermediate results are buffered.
///
/// # Arguments
///
/// * `reader` - A mutable reference to the input reader providing the data stream.
/// * `writer` - A mutable reference to the output writer where the fully filtered data is sent.
///
/// # Returns
///
/// A `Result` indicating success (`Ok(())`) or failure with an `io::Error` if any filter in the chain fails.
///
/// # Examples
///
/// ```
/// # use std::io::{Read, Write, Result};
/// # use keep::filter_plugin::{FilterChain, HeadBytesFilter};
/// let mut chain = FilterChain::new();
/// chain.add_plugin(Box::new(HeadBytesFilter::new(100)));
/// # let mut input_reader: &mut dyn Read = &mut std::io::empty();
/// # let mut output_writer: Vec<u8> = Vec::new();
/// # chain.filter(&mut input_reader, &mut output_writer)?;
/// # Ok::<(), std::io::Error>(())
/// ```
pub fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
if self.plugins.is_empty() {
// If no plugins, just copy the input to output
std::io::copy(reader, writer)?;
return Ok(());
}
// For multiple plugins, we need to chain them together
// We'll use a bounded buffer to hold intermediate results
let mut bounded_writer = BoundedVecWriter::new(MAX_FILTER_BUFFER_SIZE);
std::io::copy(reader, &mut bounded_writer)?;
let mut current_data = bounded_writer.into_inner();
// Store the plugins length to avoid borrowing issues
let plugins_len = self.plugins.len();
for i in 0..plugins_len {
// Create a cursor for the current data
let mut input = std::io::Cursor::new(std::mem::take(&mut current_data));
// For the last plugin, write directly to the output writer
if i == plugins_len - 1 {
self.plugins[i].filter(&mut input, writer)?;
} else {
// For intermediate plugins, write to a buffer
let mut output_vec = Vec::new();
self.plugins[i].filter(&mut input, &mut output_vec)?;
if output_vec.len() > MAX_FILTER_BUFFER_SIZE {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"Filter output size ({} bytes) exceeds maximum filter buffer size ({} bytes).",
output_vec.len(),
MAX_FILTER_BUFFER_SIZE
),
));
}
current_data = output_vec;
}
}
Ok(())
}
}
/// Parses a filter string into a `FilterChain`.
///
/// # Arguments
///
/// * `filter_str` - The filter string specifying the chain, e.g., "head_lines(10)|grep(pattern=error)".
///
/// # Returns
///
/// A `Result` containing the parsed `FilterChain` on success, or an `io::Error` if the string is invalid.
pub fn parse_filter_string(filter_str: &str) -> Result<FilterChain> {
let mut chain = FilterChain::new();
for part in filter_str.split('|') {
let part = part.trim();
if part.is_empty() {
continue;
}
// Parse the filter name and parameters
if let Some((filter_name, params)) = part.split_once('(') {
if let Some(params) = params.strip_suffix(')') {
// Parse parameters
let mut options = HashMap::new();
let mut unnamed_params = Vec::new();
// Split parameters by commas
for param in params.split(',') {
let param = param.trim();
if param.is_empty() {
continue;
}
// Check if it's a named parameter (key=value)
if let Some((key, value)) = param.split_once('=') {
let key = key.trim();
let value = parse_option_value(value.trim())?;
options.insert(key.to_string(), value);
} else {
// Unnamed parameter
let value = parse_option_value(param)?;
unnamed_params.push(value);
}
}
// Create the appropriate filter plugin
if let Ok(filter_type) = FilterType::from_str(filter_name) {
let plugin =
create_filter_with_options(filter_type, &unnamed_params, &options)?;
chain.add_plugin(plugin);
continue;
}
}
} else {
// Handle filters without parameters
if let Ok(filter_type) = FilterType::from_str(part) {
match filter_type {
FilterType::StripAnsi => {
chain.add_plugin(Box::new(strip_ansi::StripAnsiFilter::new()));
continue;
}
_ => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Filter '{part}' requires parameters"),
));
}
}
}
}
// If we get here, the filter wasn't recognized
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Unknown filter: {part}"),
));
}
Ok(chain)
}
/// Creates a filter plugin with the given options.
///
/// # Arguments
///
/// * `filter_type` - The enum variant indicating the type of filter to instantiate.
/// * `unnamed_params` - A slice of unnamed JSON parameters passed to the filter.
/// * `named_options` - A hashmap of named options as key-value pairs.
///
/// # Returns
///
/// A `Result` containing a boxed `FilterPlugin` on success, or an `io::Error` if creation fails.
fn create_filter_with_options(
filter_type: FilterType,
unnamed_params: &[serde_json::Value],
named_options: &HashMap<String, serde_json::Value>,
) -> Result<Box<dyn FilterPlugin>> {
// Get the default options for this filter type by creating a temporary instance
// To do this, we need to create a default instance of the appropriate filter
let option_defs = match filter_type {
#[cfg(feature = "filter_grep")]
FilterType::Grep => grep::GrepFilter::new("".to_string())?.options(),
FilterType::HeadBytes => head::HeadBytesFilter::new(0).options(),
FilterType::HeadLines => head::HeadLinesFilter::new(0).options(),
FilterType::TailBytes => tail::TailBytesFilter::new(0).options(),
FilterType::TailLines => tail::TailLinesFilter::new(0).options(),
FilterType::SkipBytes => skip::SkipBytesFilter::new(0).options(),
FilterType::SkipLines => skip::SkipLinesFilter::new(0).options(),
FilterType::StripAnsi => strip_ansi::StripAnsiFilter::new().options(),
#[cfg(feature = "meta_tokens")]
FilterType::HeadTokens => tokens::HeadTokensFilter::new(0).options(),
#[cfg(feature = "meta_tokens")]
FilterType::SkipTokens => tokens::SkipTokensFilter::new(0).options(),
#[cfg(feature = "meta_tokens")]
FilterType::TailTokens => tokens::TailTokensFilter::new(0).options(),
};
let mut options = HashMap::new();
// Process unnamed parameters
if unnamed_params.len() > option_defs.len() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"Too many unnamed parameters (expected at most {})",
option_defs.len()
),
));
}
for (i, param) in unnamed_params.iter().enumerate() {
if i >= option_defs.len() {
break;
}
let option_name = &option_defs[i].name;
options.insert(option_name.clone(), param.clone());
}
// Process named options
for (key, value) in named_options {
// Check if the option exists
if !option_defs.iter().any(|opt| &opt.name == key) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Unknown option '{key}'"),
));
}
options.insert(key.clone(), value.clone());
}
// Fill in defaults and check required options
for opt_def in option_defs {
if !options.contains_key(&opt_def.name) {
if let Some(default) = &opt_def.default {
options.insert(opt_def.name.clone(), default.clone());
} else if opt_def.required {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Missing required option '{}'", opt_def.name),
));
}
}
}
// Create the specific filter with the processed options
create_specific_filter(filter_type, &options)
}
/// Creates a specific filter instance based on type and options.
///
/// # Arguments
///
/// * `filter_type` - The enum variant indicating the type of filter to instantiate.
/// * `options` - A reference to the hashmap of processed options for the filter.
///
/// # Returns
///
/// A `Result` containing a boxed `FilterPlugin` on success, or an `io::Error` if instantiation fails.
fn create_specific_filter(
filter_type: FilterType,
options: &HashMap<String, serde_json::Value>,
) -> Result<Box<dyn FilterPlugin>> {
match filter_type {
#[cfg(feature = "filter_grep")]
FilterType::Grep => {
let pattern = options
.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"grep filter requires 'pattern' parameter",
)
})?;
grep::GrepFilter::new(pattern.to_string()).map(|f| Box::new(f) as Box<dyn FilterPlugin>)
}
FilterType::HeadBytes => {
let count = options
.get("count")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"head_bytes filter requires 'count' parameter",
)
})?;
Ok(Box::new(head::HeadBytesFilter::new(count)))
}
FilterType::HeadLines => {
let count = options
.get("count")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"head_lines filter requires 'count' parameter",
)
})?;
Ok(Box::new(head::HeadLinesFilter::new(count)))
}
FilterType::TailBytes => {
let count = options
.get("count")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"tail_bytes filter requires 'count' parameter",
)
})?;
Ok(Box::new(tail::TailBytesFilter::new(count)))
}
FilterType::TailLines => {
let count = options
.get("count")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"tail_lines filter requires 'count' parameter",
)
})?;
Ok(Box::new(tail::TailLinesFilter::new(count)))
}
FilterType::SkipBytes => {
let count = options
.get("count")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"skip_bytes filter requires 'count' parameter",
)
})?;
Ok(Box::new(skip::SkipBytesFilter::new(count)))
}
FilterType::SkipLines => {
let count = options
.get("count")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"skip_lines filter requires 'count' parameter",
)
})?;
Ok(Box::new(skip::SkipLinesFilter::new(count)))
}
FilterType::StripAnsi => {
// StripAnsi doesn't take any parameters
if !options.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"strip_ansi filter doesn't take parameters",
));
}
Ok(Box::new(strip_ansi::StripAnsiFilter::new()))
}
#[cfg(feature = "meta_tokens")]
FilterType::HeadTokens => {
let count = options
.get("count")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"head_tokens filter requires 'count' parameter",
)
})?;
let (encoding, tokenizer) = parse_encoding_option(options);
let mut f = tokens::HeadTokensFilter::new(count);
f.tokenizer = tokenizer;
f.encoding = encoding;
Ok(Box::new(f))
}
#[cfg(feature = "meta_tokens")]
FilterType::SkipTokens => {
let count = options
.get("count")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"skip_tokens filter requires 'count' parameter",
)
})?;
let (encoding, tokenizer) = parse_encoding_option(options);
let mut f = tokens::SkipTokensFilter::new(count);
f.tokenizer = tokenizer;
f.encoding = encoding;
Ok(Box::new(f))
}
#[cfg(feature = "meta_tokens")]
FilterType::TailTokens => {
let count = options
.get("count")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"tail_tokens filter requires 'count' parameter",
)
})?;
let (encoding, tokenizer) = parse_encoding_option(options);
let mut f = tokens::TailTokensFilter::new(count);
f.tokenizer = tokenizer;
f.encoding = encoding;
Ok(Box::new(f))
}
}
}
#[cfg(feature = "meta_tokens")]
fn parse_encoding_option(
options: &std::collections::HashMap<String, serde_json::Value>,
) -> (crate::tokenizer::TokenEncoding, crate::tokenizer::Tokenizer) {
let encoding = options
.get("encoding")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<crate::tokenizer::TokenEncoding>().ok())
.unwrap_or_default();
let tokenizer = crate::tokenizer::get_tokenizer(encoding).clone();
(encoding, tokenizer)
}
/// Parses an option value from a string into a JSON value.
///
/// # Arguments
///
/// * `input` - The input string.
///
/// # Returns
///
/// A `Result` containing the parsed JSON value.
fn parse_option_value(input: &str) -> Result<serde_json::Value> {
// Remove quotes if present
let input = input.trim_matches(|c| c == '\'' || c == '"');
// Try to parse as number
if let Ok(num) = input.parse::<i64>() {
return Ok(serde_json::Value::Number(num.into()));
}
if let Ok(num) = input.parse::<f64>()
&& let Some(number) = serde_json::Number::from_f64(num)
{
return Ok(serde_json::Value::Number(number));
}
// Try to parse as boolean
if input.eq_ignore_ascii_case("true") {
return Ok(serde_json::Value::Bool(true));
}
if input.eq_ignore_ascii_case("false") {
return Ok(serde_json::Value::Bool(false));
}
// Treat as string
Ok(serde_json::Value::String(input.to_string()))
}

133
src/filter_plugin/skip.rs Normal file
View File

@@ -0,0 +1,133 @@
use super::{FilterOption, FilterPlugin};
use crate::common::PIPESIZE;
use crate::services::filter_service::register_filter_plugin;
use std::io::{BufRead, Read, Result, Write};
/// A filter that skips the first N bytes from the input stream.
#[derive(Clone)]
pub struct SkipBytesFilter {
remaining: usize,
}
impl SkipBytesFilter {
/// Creates a new `SkipBytesFilter` that will skip the specified number of bytes.
///
/// # Arguments
///
/// * `count` - The number of bytes to skip from the beginning of the input.
pub fn new(count: usize) -> Self {
Self { remaining: count }
}
}
impl FilterPlugin for SkipBytesFilter {
/// Filters the input by skipping the first N bytes and writing the rest to the output.
///
/// # Arguments
///
/// * `reader` - Mutable reference to the input reader providing the data stream.
/// * `writer` - Mutable reference to the output writer where filtered data is sent.
///
/// # Returns
///
/// Returns `Ok(())` on success, or an `io::Error` if reading or writing fails.
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
// Skip bytes in chunks
if self.remaining > 0 {
let mut buffer = vec![0; PIPESIZE];
while self.remaining > 0 {
let to_read = std::cmp::min(self.remaining, PIPESIZE);
let bytes_read = reader.read(&mut buffer[..to_read])?;
if bytes_read == 0 {
break;
}
self.remaining -= bytes_read;
}
}
// Copy the remaining data using io::copy for efficiency
std::io::copy(reader, writer)?;
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self {
remaining: self.remaining,
})
}
/// Returns the configuration options for this filter.
fn options(&self) -> Vec<FilterOption> {
crate::filter_plugin::count_option()
}
fn description(&self) -> &str {
"Skip the first N bytes"
}
}
/// A filter that skips the first N lines from the input stream.
#[derive(Clone)]
pub struct SkipLinesFilter {
remaining: usize,
}
impl SkipLinesFilter {
/// Creates a new `SkipLinesFilter` that will skip the specified number of lines.
///
/// # Arguments
///
/// * `count` - The number of lines to skip from the beginning of the input.
pub fn new(count: usize) -> Self {
Self { remaining: count }
}
}
impl FilterPlugin for SkipLinesFilter {
/// Filters the input by skipping the first N lines and writing the rest to the output.
///
/// # Arguments
///
/// * `reader` - Mutable reference to the input reader providing the data stream.
/// * `writer` - Mutable reference to the output writer where filtered data is sent.
///
/// # Returns
///
/// Returns `Ok(())` on success, or an `io::Error` if reading or writing fails.
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
let mut buf_reader = std::io::BufReader::new(reader);
for line in buf_reader.by_ref().lines() {
let line = line?;
if self.remaining > 0 {
self.remaining -= 1;
} else {
writeln!(writer, "{line}")?;
}
}
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self {
remaining: self.remaining,
})
}
/// Returns the configuration options for this filter.
fn options(&self) -> Vec<FilterOption> {
crate::filter_plugin::count_option()
}
fn description(&self) -> &str {
"Skip the first N lines"
}
}
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_skip_filters() {
register_filter_plugin("skip_bytes", || Box::new(SkipBytesFilter::new(0)))
.expect("Failed to register skip_bytes filter");
register_filter_plugin("skip_lines", || Box::new(SkipLinesFilter::new(0)))
.expect("Failed to register skip_lines filter");
}

View File

@@ -0,0 +1,53 @@
use super::{FilterOption, FilterPlugin};
use std::io::{Read, Result, Write};
use strip_ansi_escapes::Writer;
/// A filter that removes ANSI escape sequences from the input.
///
/// # Fields
///
/// None, stateless filter.
#[derive(Default, Clone)]
pub struct StripAnsiFilter;
impl StripAnsiFilter {
/// Creates a new `StripAnsiFilter`.
///
/// # Returns
///
/// A new instance of `StripAnsiFilter`.
pub fn new() -> Self {
Self
}
}
impl FilterPlugin for StripAnsiFilter {
/// Filters the input by stripping ANSI escape sequences and writing the plain text to the output.
///
/// # Arguments
///
/// * `reader` - Mutable reference to the input reader providing the data stream with potential ANSI codes.
/// * `writer` - Mutable reference to the output writer where plain text is sent.
///
/// # Returns
///
/// Returns `Ok(())` on success, or an `io::Error` if reading or writing fails.
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
let mut ansi_writer = Writer::new(writer);
std::io::copy(reader, &mut ansi_writer)?;
ansi_writer.flush()?;
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self)
}
fn options(&self) -> Vec<FilterOption> {
Vec::new()
}
fn description(&self) -> &str {
"Strip ANSI escape sequences"
}
}

151
src/filter_plugin/tail.rs Normal file
View File

@@ -0,0 +1,151 @@
use super::{FilterOption, FilterPlugin};
use crate::common::PIPESIZE;
use crate::services::filter_service::register_filter_plugin;
use std::collections::VecDeque;
use std::io::{BufRead, Read, Result, Write};
#[derive(Clone)]
pub struct TailBytesFilter {
buffer: VecDeque<u8>,
count: usize,
}
impl TailBytesFilter {
/// Creates a new `TailBytesFilter` that will keep the last specified number of bytes.
///
/// # Arguments
///
/// * `count` - The number of bytes to retain from the end of the input.
pub fn new(count: usize) -> Self {
Self {
buffer: VecDeque::with_capacity(count),
count,
}
}
}
impl FilterPlugin for TailBytesFilter {
/// Filters the input by keeping only the last N bytes and writing them to the output.
///
/// # Arguments
///
/// * `reader` - Mutable reference to the input reader providing the data stream.
/// * `writer` - Mutable reference to the output writer where filtered data is sent.
///
/// # Returns
///
/// Returns `Ok(())` on success, or an `io::Error` if reading or writing fails.
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
let mut temp_buffer = vec![0; PIPESIZE];
loop {
let bytes_read = reader.read(&mut temp_buffer)?;
if bytes_read == 0 {
break;
}
// Add new data to the buffer
for &byte in &temp_buffer[..bytes_read] {
if self.buffer.len() == self.count {
self.buffer.pop_front();
}
self.buffer.push_back(byte);
}
}
// Write the buffered data at the end
let result: Vec<u8> = self.buffer.iter().cloned().collect();
writer.write_all(&result)?;
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self {
buffer: self.buffer.clone(),
count: self.count,
})
}
/// Returns the configuration options for this filter.
fn options(&self) -> Vec<FilterOption> {
crate::filter_plugin::count_option()
}
fn description(&self) -> &str {
"Read the last N bytes"
}
}
/// A filter that reads the last N lines from the input stream.
#[derive(Clone)]
pub struct TailLinesFilter {
lines: VecDeque<String>,
count: usize,
}
impl TailLinesFilter {
/// Creates a new `TailLinesFilter` that will keep the last specified number of lines.
///
/// # Arguments
///
/// * `count` - The number of lines to retain from the end of the input.
pub fn new(count: usize) -> Self {
Self {
lines: VecDeque::with_capacity(count),
count,
}
}
}
impl FilterPlugin for TailLinesFilter {
/// Filters the input by keeping only the last N lines and writing them to the output.
///
/// # Arguments
///
/// * `reader` - Mutable reference to the input reader providing the data stream.
/// * `writer` - Mutable reference to the output writer where filtered data is sent.
///
/// # Returns
///
/// Returns `Ok(())` on success, or an `io::Error` if reading or writing fails.
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
let mut buf_reader = std::io::BufReader::new(reader);
for line in buf_reader.by_ref().lines() {
let line = line?;
if self.lines.len() == self.count {
self.lines.pop_front();
}
self.lines.push_back(line);
}
// Write the buffered lines
for line in &self.lines {
writeln!(writer, "{line}")?;
}
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self {
lines: self.lines.clone(),
count: self.count,
})
}
/// Returns the configuration options for this filter.
fn options(&self) -> Vec<FilterOption> {
crate::filter_plugin::count_option()
}
fn description(&self) -> &str {
"Read the last N lines"
}
}
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_tail_filters() {
register_filter_plugin("tail_bytes", || Box::new(TailBytesFilter::new(0)))
.expect("Failed to register tail_bytes filter");
register_filter_plugin("tail_lines", || Box::new(TailLinesFilter::new(0)))
.expect("Failed to register tail_lines filter");
}

500
src/filter_plugin/tokens.rs Normal file
View File

@@ -0,0 +1,500 @@
use super::{FilterOption, FilterPlugin};
use crate::common::PIPESIZE;
use crate::services::filter_service::register_filter_plugin;
use crate::tokenizer::{TokenEncoding, Tokenizer, get_tokenizer};
use std::io::{Read, Result, Write};
// ---------------------------------------------------------------------------
// head_tokens
// ---------------------------------------------------------------------------
#[derive(Clone)]
pub struct HeadTokensFilter {
pub remaining: usize,
pub tokenizer: Tokenizer,
pub encoding: TokenEncoding,
}
impl HeadTokensFilter {
pub fn new(count: usize) -> Self {
let encoding = TokenEncoding::default();
Self {
remaining: count,
tokenizer: get_tokenizer(encoding).clone(),
encoding,
}
}
}
impl FilterPlugin for HeadTokensFilter {
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
if self.remaining == 0 {
return Ok(());
}
let tokenizer = &self.tokenizer;
let mut buffer = vec![0u8; PIPESIZE];
let mut total_tokens = 0usize;
loop {
let n = reader.read(&mut buffer)?;
if n == 0 {
break;
}
let chunk = &buffer[..n];
let text = String::from_utf8_lossy(chunk);
let chunk_tokens = tokenizer.count(&text);
if total_tokens + chunk_tokens <= self.remaining {
// Entire chunk fits — write it directly
writer.write_all(chunk)?;
total_tokens += chunk_tokens;
if total_tokens >= self.remaining {
break;
}
} else {
// Cutoff is within this chunk — use iterator to find exact
// boundary without allocating all token strings
let tokens_to_write = self.remaining - total_tokens;
let mut byte_pos = 0usize;
for token_str in tokenizer.split_by_token_iter(&text).take(tokens_to_write) {
byte_pos += token_str
.map_err(|e| std::io::Error::other(e.to_string()))?
.len();
}
let write_len = map_lossy_pos_to_bytes(chunk, &text, byte_pos);
writer.write_all(&chunk[..write_len])?;
break;
}
}
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self {
remaining: self.remaining,
tokenizer: self.tokenizer.clone(),
encoding: self.encoding,
})
}
fn options(&self) -> Vec<FilterOption> {
vec![
FilterOption {
name: "count".to_string(),
default: None,
required: true,
},
FilterOption {
name: "encoding".to_string(),
default: Some(serde_json::Value::String("cl100k_base".to_string())),
required: false,
},
]
}
fn description(&self) -> &str {
"Read the first N LLM tokens"
}
}
// ---------------------------------------------------------------------------
// skip_tokens
// ---------------------------------------------------------------------------
#[derive(Clone)]
pub struct SkipTokensFilter {
pub remaining: usize,
pub tokenizer: Tokenizer,
pub encoding: TokenEncoding,
}
impl SkipTokensFilter {
pub fn new(count: usize) -> Self {
let encoding = TokenEncoding::default();
Self {
remaining: count,
tokenizer: get_tokenizer(encoding).clone(),
encoding,
}
}
}
impl FilterPlugin for SkipTokensFilter {
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
if self.remaining == 0 {
return std::io::copy(reader, writer).map(|_| ());
}
let tokenizer = &self.tokenizer;
let mut buffer = vec![0u8; PIPESIZE];
let mut total_tokens = 0usize;
let mut done_skipping = false;
loop {
let n = reader.read(&mut buffer)?;
if n == 0 {
break;
}
if done_skipping {
writer.write_all(&buffer[..n])?;
continue;
}
let chunk = &buffer[..n];
let text = String::from_utf8_lossy(chunk);
let chunk_tokens = tokenizer.count(&text);
if total_tokens + chunk_tokens <= self.remaining {
// Entire chunk is skipped
total_tokens += chunk_tokens;
if total_tokens >= self.remaining {
done_skipping = true;
}
} else {
// Cutoff is within this chunk — use iterator to skip past
// the boundary without allocating all token strings
let tokens_to_skip = self.remaining - total_tokens;
let mut byte_pos = 0usize;
for token_str in tokenizer.split_by_token_iter(&text).take(tokens_to_skip) {
byte_pos += token_str
.map_err(|e| std::io::Error::other(e.to_string()))?
.len();
}
let skip_len = map_lossy_pos_to_bytes(chunk, &text, byte_pos);
if skip_len < n {
writer.write_all(&chunk[skip_len..])?;
}
done_skipping = true;
}
}
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self {
remaining: self.remaining,
tokenizer: self.tokenizer.clone(),
encoding: self.encoding,
})
}
fn options(&self) -> Vec<FilterOption> {
vec![
FilterOption {
name: "count".to_string(),
default: None,
required: true,
},
FilterOption {
name: "encoding".to_string(),
default: Some(serde_json::Value::String("cl100k_base".to_string())),
required: false,
},
]
}
fn description(&self) -> &str {
"Skip the first N LLM tokens"
}
}
// ---------------------------------------------------------------------------
// tail_tokens
// ---------------------------------------------------------------------------
/// A filter that outputs only the last N tokens of the input stream.
///
#[derive(Clone)]
pub struct TailTokensFilter {
pub count: usize,
/// Buffer holding all bytes from the stream.
buffer: Vec<u8>,
pub tokenizer: Tokenizer,
pub encoding: TokenEncoding,
}
impl TailTokensFilter {
pub fn new(count: usize) -> Self {
let encoding = TokenEncoding::default();
Self {
count,
buffer: Vec::with_capacity(PIPESIZE),
tokenizer: get_tokenizer(encoding).clone(),
encoding,
}
}
}
impl FilterPlugin for TailTokensFilter {
fn filter(&mut self, reader: &mut dyn Read, writer: &mut dyn Write) -> Result<()> {
if self.count == 0 {
return Ok(());
}
let tokenizer = &self.tokenizer;
// Buffer all bytes from the stream
std::io::copy(reader, &mut self.buffer)?;
if self.buffer.is_empty() {
return Ok(());
}
let text = String::from_utf8_lossy(&self.buffer);
let token_strs = tokenizer
.split_by_token(&text)
.map_err(|e| std::io::Error::other(e.to_string()))?;
if token_strs.len() <= self.count {
// All tokens fit — write everything
writer.write_all(&self.buffer)?;
} else {
// Write only the last N tokens
let skip = token_strs.len() - self.count;
let mut byte_offset = 0usize;
for token_str in token_strs.iter().take(skip) {
byte_offset += token_str.len();
}
let write_len = map_lossy_pos_to_bytes(&self.buffer, &text, byte_offset);
if write_len < self.buffer.len() {
writer.write_all(&self.buffer[write_len..])?;
}
}
Ok(())
}
fn clone_box(&self) -> Box<dyn FilterPlugin> {
Box::new(Self {
count: self.count,
buffer: Vec::new(),
tokenizer: self.tokenizer.clone(),
encoding: self.encoding,
})
}
fn options(&self) -> Vec<FilterOption> {
vec![
FilterOption {
name: "count".to_string(),
default: None,
required: true,
},
FilterOption {
name: "encoding".to_string(),
default: Some(serde_json::Value::String("cl100k_base".to_string())),
required: false,
},
]
}
fn description(&self) -> &str {
"Read the last N LLM tokens"
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Map a byte position in a lossy string back to a position in the original byte slice.
///
/// `String::from_utf8_lossy` replaces invalid UTF-8 bytes with the Unicode
/// replacement character (U+FFFD), which encodes to 3 bytes in UTF-8. This
/// function walks both the original bytes and the lossy string in lockstep,
/// finding the original byte position that corresponds to `lossy_pos`.
fn map_lossy_pos_to_bytes(original: &[u8], lossy: &str, lossy_pos: usize) -> usize {
if lossy_pos == 0 {
return 0;
}
let replacement = '\u{FFFD}';
let replacement_len = replacement.len_utf8(); // 3 bytes
let mut orig_idx = 0usize;
let mut lossy_idx = 0usize;
let lossy_bytes = lossy.as_bytes();
while lossy_idx < lossy_pos && orig_idx < original.len() {
// Try to decode the next character from the original bytes
match std::str::from_utf8(&original[orig_idx..]) {
Ok("") => break,
Ok(s) => {
let ch = s.chars().next().unwrap();
let ch_len = ch.len_utf8();
// Check if this is a replacement character in the lossy string
if ch == replacement
&& lossy_idx + replacement_len <= lossy_pos
&& lossy_bytes[lossy_idx..].starts_with(
&replacement.encode_utf8(&mut [0; 4]).as_bytes()[..replacement_len],
)
{
// Could be a real U+FFFD or a replacement of invalid bytes.
// If the original byte at this position is valid UTF-8 start, it's real.
if original[orig_idx] < 0x80 || original[orig_idx] >= 0xC0 {
// Real character
orig_idx += ch_len;
lossy_idx += ch_len;
} else {
// Invalid byte that was replaced — advance original by 1
orig_idx += 1;
lossy_idx += replacement_len;
}
} else {
orig_idx += ch_len;
lossy_idx += ch_len;
}
}
Err(e) => {
let valid = e.valid_up_to();
if valid > 0 {
// Some valid bytes, then invalid
orig_idx += valid;
lossy_idx += valid;
} else {
// Invalid byte — in lossy it becomes 3-byte replacement char
orig_idx += 1;
lossy_idx += replacement_len;
}
}
}
}
orig_idx.min(original.len())
}
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
#[ctor::ctor]
fn register_token_filters() {
register_filter_plugin("head_tokens", || Box::new(HeadTokensFilter::new(0)))
.expect("Failed to register head_tokens filter");
register_filter_plugin("skip_tokens", || Box::new(SkipTokensFilter::new(0)))
.expect("Failed to register skip_tokens filter");
register_filter_plugin("tail_tokens", || Box::new(TailTokensFilter::new(0)))
.expect("Failed to register tail_tokens filter");
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn make_tokenizer() -> Tokenizer {
get_tokenizer(TokenEncoding::Cl100kBase).clone()
}
#[test]
fn test_head_tokens_basic() {
let mut filter = HeadTokensFilter::new(3);
filter.tokenizer = make_tokenizer();
let input = b"The quick brown fox";
let mut output = Vec::new();
filter.filter(&mut Cursor::new(input), &mut output).unwrap();
let result = String::from_utf8_lossy(&output);
// "The quick brown" is typically 3 tokens
assert!(!result.is_empty());
assert!(result.len() <= input.len());
}
#[test]
fn test_head_tokens_zero() {
let mut filter = HeadTokensFilter::new(0);
filter.tokenizer = make_tokenizer();
let input = b"The quick brown fox";
let mut output = Vec::new();
filter.filter(&mut Cursor::new(input), &mut output).unwrap();
assert!(output.is_empty());
}
#[test]
fn test_head_tokens_more_than_available() {
let mut filter = HeadTokensFilter::new(1000);
filter.tokenizer = make_tokenizer();
let input = b"Hello world";
let mut output = Vec::new();
filter.filter(&mut Cursor::new(input), &mut output).unwrap();
assert_eq!(output, input);
}
#[test]
fn test_skip_tokens_basic() {
let mut filter = SkipTokensFilter::new(2);
filter.tokenizer = make_tokenizer();
let input = b"The quick brown fox";
let mut output = Vec::new();
filter.filter(&mut Cursor::new(input), &mut output).unwrap();
let result = String::from_utf8_lossy(&output);
// Should have skipped some tokens
assert!(result.len() < input.len());
}
#[test]
fn test_skip_tokens_zero() {
let mut filter = SkipTokensFilter::new(0);
filter.tokenizer = make_tokenizer();
let input = b"Hello world";
let mut output = Vec::new();
filter.filter(&mut Cursor::new(input), &mut output).unwrap();
assert_eq!(output, input);
}
#[test]
fn test_tail_tokens_basic() {
let mut filter = TailTokensFilter::new(2);
filter.tokenizer = make_tokenizer();
let input = b"The quick brown fox jumps over the lazy dog";
let mut output = Vec::new();
filter.filter(&mut Cursor::new(input), &mut output).unwrap();
let result = String::from_utf8_lossy(&output);
// Should only have last 2 tokens
assert!(!result.is_empty());
assert!(result.len() < input.len());
}
#[test]
fn test_tail_tokens_zero() {
let mut filter = TailTokensFilter::new(0);
filter.tokenizer = make_tokenizer();
let input = b"Hello world";
let mut output = Vec::new();
filter.filter(&mut Cursor::new(input), &mut output).unwrap();
assert!(output.is_empty());
}
#[test]
fn test_map_lossy_pos_ascii() {
let original = b"Hello world";
let lossy = String::from_utf8_lossy(original);
assert_eq!(map_lossy_pos_to_bytes(original, &lossy, 5), 5);
assert_eq!(map_lossy_pos_to_bytes(original, &lossy, 0), 0);
assert_eq!(map_lossy_pos_to_bytes(original, &lossy, 11), 11);
}
#[test]
fn test_map_lossy_pos_with_invalid_utf8() {
let original = b"Hello\x80world";
let lossy = String::from_utf8_lossy(original);
// lossy = "Hello\u{FFFD}world" (13 bytes)
// Position 5 in lossy = after "Hello" = position 5 in original
assert_eq!(map_lossy_pos_to_bytes(original, &lossy, 5), 5);
// Position 8 in lossy = "Hello\u{FFFD}" = position 6 in original
// (the invalid byte \x80 at position 5 was replaced)
assert_eq!(map_lossy_pos_to_bytes(original, &lossy, 8), 6);
}
}

View File

@@ -0,0 +1,33 @@
use std::io::Result;
/// Creates a filter chain from a filter string specification.
///
/// # Arguments
///
/// * `filter_str` - The string describing the filter chain, such as "head_lines(10)|grep(pattern=error)"
///
/// # Returns
///
/// * `Result<Option<super::FilterChain>>` - A result containing:
/// * `Ok(Some(FilterChain))` if parsing succeeds
/// * `Ok(None)` if the filter string is empty
/// * `Err(io::Error)` if the string is invalid
pub fn create_filter_chain(filter_str: &str) -> Result<Option<super::FilterChain>> {
super::parse_filter_string(filter_str).map(Some)
}
/// Parses a string into a number of type T.
///
/// # Arguments
///
/// * `s` - The string to parse into a number
///
/// # Returns
///
/// * `Result<T>` - A result containing:
/// * `Ok(T)` - The parsed number on success
/// * `Err(io::Error)` - If the string is not a valid number
pub fn parse_number<T: std::str::FromStr>(s: &str) -> Result<T> {
s.parse::<T>()
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid number"))
}

225
src/import_tar.rs Normal file
View File

@@ -0,0 +1,225 @@
use anyhow::{Context, Result, anyhow};
use log::debug;
use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::path::Path;
use std::str::FromStr;
use tempfile::TempDir;
use tar::Archive;
use crate::common::PIPESIZE;
use crate::compression_engine::CompressionType;
use crate::db;
use crate::modes::common::ImportMeta;
/// Represents a parsed tar entry from an export archive.
struct TarEntry {
/// Path to the extracted data file in the temp directory.
data_path: Option<std::path::PathBuf>,
/// Path to the extracted meta file in the temp directory.
meta_path: Option<std::path::PathBuf>,
}
/// Import all items from a `.keep.tar` archive.
///
/// Items are imported in ascending order of their original IDs,
/// ensuring chronological ordering is preserved. Each imported item
/// receives a new auto-incremented ID from the target database.
///
/// # Arguments
///
/// * `tar_path` - Path to the `.keep.tar` file.
/// * `conn` - Mutable database connection.
/// * `data_path` - Path to the data storage directory.
///
/// # Returns
///
/// A list of newly assigned item IDs.
pub fn import_from_tar(
tar_path: &Path,
conn: &mut rusqlite::Connection,
data_path: &Path,
) -> Result<Vec<i64>> {
let file = fs::File::open(tar_path)
.with_context(|| format!("Cannot open tar file: {}", tar_path.display()))?;
let mut archive = Archive::new(file);
let tmp_dir = TempDir::new().context("Cannot create temporary directory for import")?;
let tmp_path = tmp_dir.path();
// Extract entries to temp dir
let mut entries_map: HashMap<i64, TarEntry> = HashMap::new();
for entry_result in archive.entries().context("Cannot read tar entries")? {
let mut entry = entry_result.context("Cannot read tar entry")?;
let entry_path = entry.path().context("Cannot get entry path")?.to_path_buf();
let path_str = entry_path.to_string_lossy().replace('\\', "/");
// Reject path traversal attempts
if path_str.starts_with('/') || path_str.starts_with("..") || path_str.contains("/../") {
return Err(anyhow!("Rejected path traversal entry: {path_str}"));
}
// Skip directory entries
if entry.header().entry_type().is_dir() {
debug!("IMPORT_TAR: Skipping directory entry: {path_str}");
continue;
}
// Parse: <dir>/<id>.data.<compression> or <dir>/<id>.meta.yml
let filename = entry_path
.file_name()
.ok_or_else(|| anyhow!("Invalid entry path: {path_str}"))?
.to_string_lossy();
let (orig_id, is_data) = if let Some(id_str) = filename.strip_suffix(".meta.yml") {
let id: i64 = id_str
.parse()
.with_context(|| format!("Invalid ID in entry: {path_str}"))?;
(id, false)
} else if let Some(dot_pos) = filename.find(".data.") {
let id_str = &filename[..dot_pos];
let id: i64 = id_str
.parse()
.with_context(|| format!("Invalid ID in entry: {path_str}"))?;
(id, true)
} else {
debug!("IMPORT_TAR: Skipping unrecognized entry: {path_str}");
continue;
};
let entry_ref = entries_map.entry(orig_id).or_insert_with(|| TarEntry {
data_path: None,
meta_path: None,
});
if is_data {
let dest = tmp_path.join(format!("{orig_id}.data"));
let mut dest_file = fs::File::create(&dest).context("Cannot create temp data file")?;
let mut buf = [0u8; PIPESIZE];
loop {
let n = entry.read(&mut buf)?;
if n == 0 {
break;
}
dest_file.write_all(&buf[..n])?;
}
entry_ref.data_path = Some(dest);
debug!("IMPORT_TAR: Extracted data for original ID {orig_id}");
} else {
let dest = tmp_path.join(format!("{orig_id}.meta.yml"));
let mut dest_file = fs::File::create(&dest).context("Cannot create temp meta file")?;
let mut buf = [0u8; PIPESIZE];
loop {
let n = entry.read(&mut buf)?;
if n == 0 {
break;
}
dest_file.write_all(&buf[..n])?;
}
entry_ref.meta_path = Some(dest);
debug!("IMPORT_TAR: Extracted meta for original ID {orig_id}");
}
}
if entries_map.is_empty() {
return Err(anyhow!("No items found in archive"));
}
// Sort by original ID ascending
let mut sorted_ids: Vec<i64> = entries_map.keys().copied().collect();
sorted_ids.sort_unstable();
let mut imported_ids = Vec::new();
for orig_id in sorted_ids {
let entry = entries_map.get(&orig_id).expect("ID should exist in map");
let meta_path = entry
.meta_path
.as_ref()
.ok_or_else(|| anyhow!("Item {orig_id} missing .meta.yml entry"))?;
let data_path_entry = entry
.data_path
.as_ref()
.ok_or_else(|| anyhow!("Item {orig_id} missing .data entry"))?;
// Parse metadata
let meta_yaml = fs::read_to_string(meta_path)
.with_context(|| format!("Cannot read meta file for item {orig_id}"))?;
let import_meta: ImportMeta = serde_yaml::from_str(&meta_yaml)
.with_context(|| format!("Cannot parse meta file for item {orig_id}"))?;
// Validate compression type
CompressionType::from_str(&import_meta.compression).map_err(|_| {
anyhow!(
"Invalid compression type '{}' for item {}",
import_meta.compression,
orig_id
)
})?;
// Create item with original timestamp
let item = db::insert_item_with_ts(conn, import_meta.ts, &import_meta.compression)?;
let new_id = item.id.context("New item missing ID")?;
// Set tags
let tags = if !import_meta.tags.is_empty() {
db::set_item_tags(conn, item.clone(), &import_meta.tags)?;
import_meta.tags.clone()
} else {
Vec::new()
};
// Stream data to storage
let mut storage_path = data_path.to_path_buf();
storage_path.push(new_id.to_string());
let mut reader = fs::File::open(data_path_entry)
.with_context(|| format!("Cannot read data file for item {orig_id}"))?;
let mut writer = fs::File::create(&storage_path)
.with_context(|| format!("Cannot create storage file for item {new_id}"))?;
let mut buf = [0u8; PIPESIZE];
let mut total = 0i64;
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
writer.write_all(&buf[..n])?;
total += n as i64;
}
if total == 0 {
return Err(anyhow!("Item {orig_id} has empty data file"));
}
// Set metadata
for (key, value) in &import_meta.metadata {
db::query_upsert_meta(
conn,
db::Meta {
id: new_id,
name: key.clone(),
value: value.clone(),
},
)?;
}
// Update item sizes
let size_to_record = import_meta.uncompressed_size.unwrap_or(total);
let mut updated_item = item;
updated_item.uncompressed_size = Some(size_to_record);
updated_item.compressed_size = Some(std::fs::metadata(&storage_path)?.len() as i64);
updated_item.closed = true;
db::update_item(conn, updated_item)?;
log::info!("KEEP: Imported item {new_id} (was {orig_id}) tags: {tags:?}");
imported_ids.push(new_id);
}
Ok(imported_ids)
}

109
src/lib.rs Normal file
View File

@@ -0,0 +1,109 @@
#![deny(clippy::all)]
#![deny(unsafe_code)]
#![allow(unused_imports)]
//! Keep library for managing temporary files with compression and metadata.
//!
//! This library provides core functionality for the Keep application, including
//! database operations, compression engines, item services, and plugin systems
//! for metadata and filtering. It supports CLI modes, server APIs, and plugin
//! registration via ctors.
//!
//! # Usage
//!
//! Add to Cargo.toml and use re-exported types:
//! ```toml
//! [dependencies]
//! keep = "0.1"
//! ```
//!
//! ```rust
//! # use keep::Args;
//! # use clap::Parser;
//! let args = Args::parse();
//! ```
//!
//! # Features
//!
//! - `server`: Enables Axum-based HTTP server.
//! - `gzip`, `lz4`: Built-in compression support.
//! - `magic`: File type detection via libmagic.
// Re-export modules for testing
pub mod args;
pub mod common;
pub mod compression_engine;
pub mod config;
pub mod db;
pub mod export_tar;
pub mod filter_plugin;
pub mod import_tar;
pub mod meta_plugin;
pub mod modes;
pub mod services;
#[cfg(feature = "client")]
pub mod client;
#[cfg(feature = "meta_tokens")]
pub mod tokenizer;
// Re-export Args struct for library usage
pub use args::Args;
// Re-export PIPESIZE constant
pub use common::PIPESIZE;
pub use services::CoreError;
// Import all filter plugins to ensure they register themselves
#[allow(unused_imports)]
#[cfg(feature = "filter_grep")]
use filter_plugin::grep;
#[allow(unused_imports)]
use filter_plugin::{head, skip, strip_ansi, tail};
#[cfg(feature = "meta_tokens")]
#[allow(unused_imports)]
use filter_plugin::tokens as token_filters;
use crate::meta_plugin::{
cwd, digest, env, exec, hostname, keep_pid, read_rate, read_time, shell, shell_pid, user,
};
#[cfg(feature = "meta_magic")]
#[allow(unused_imports)]
use crate::meta_plugin::magic_file;
#[cfg(feature = "meta_tokens")]
#[allow(unused_imports)]
use crate::meta_plugin::tokens;
#[cfg(feature = "meta_infer")]
#[allow(unused_imports)]
use crate::meta_plugin::infer_plugin;
#[cfg(feature = "meta_tree_magic_mini")]
#[allow(unused_imports)]
use crate::meta_plugin::tree_magic_mini;
/// Initializes plugins at library load time.
///
/// Plugin registration happens automatically via `#[ctor]` constructors
/// when each plugin module is loaded. The explicit module imports in
/// `lib.rs` guarantee this happens at library initialization time.
///
/// This function exists as a public API entry point for callers that
/// want to explicitly ensure plugins are ready. It intentionally does
/// no additional work.
///
/// # Examples
///
/// ```
/// keep::init_plugins();
/// ```
pub fn init_plugins() {
// Plugins self-register via #[ctor] on module load.
// The use-statements in lib.rs guarantee module inclusion.
}
#[cfg(test)]
mod tests;

View File

@@ -1,218 +1,17 @@
use std::path::PathBuf;
use std::io::Write;
use std::time::Instant;
use anyhow::{Context, Error, Result, anyhow};
use clap::*;
use clap::error::ErrorKind;
use clap::*;
use log::*;
mod modes;
extern crate directories;
use directories::ProjectDirs;
extern crate prettytable;
use std::str::FromStr;
extern crate lazy_static;
pub mod compression_engine;
pub mod db;
pub mod plugins;
pub mod meta_plugin;
//pub mod item;
extern crate term;
extern crate serde_json;
extern crate serde_yaml;
extern crate serde;
mod common;
/**
* Main struct for command-line arguments.
*/
#[derive(Parser, Debug, Clone)]
#[command(author, version, about, long_about = None)]
pub struct Args {
#[command(flatten)]
mode: ModeArgs,
#[command(flatten)]
item: ItemArgs,
#[command(flatten)]
options: OptionsArgs,
#[arg(help("A list of either item IDs or tags"))]
ids_or_tags: Vec<NumberOrString>,
}
/**
* Struct for mode-specific arguments.
*/
#[derive(Parser, Debug, Clone)]
struct ModeArgs {
#[arg(group("mode"), help_heading("Mode Options"), short, long, conflicts_with_all(["get", "diff", "list", "update", "delete", "info", "status"]))]
#[arg(help("Save an item using any tags or metadata provided"))]
save: bool,
#[arg(group("mode"), help_heading("Mode Options"), short, long, conflicts_with_all(["save", "diff", "list", "update", "delete", "info", "status"]))]
#[arg(help(
"Get an item either by it's ID or by a combination of matching tags and metatdata"
))]
get: bool,
#[arg(group("mode"), help_heading("Mode Options"), long, conflicts_with_all(["save", "get", "list", "update", "delete", "info", "status"]))]
#[arg(help("Show a diff between two items by ID"))]
diff: bool,
#[arg(group("mode"), help_heading("Mode Options"), short, long, conflicts_with_all(["save", "get", "diff", "update", "delete", "info", "status"]))]
#[arg(help("List items, filtering on tags or metadata if given"))]
list: bool,
#[arg(group("mode"), help_heading("Mode Options"), short, long, conflicts_with_all(["save", "get", "diff", "list", "delete", "info", "status"]), requires("ids_or_tags"))]
#[arg(help("Update a specified item ID's tags and/or metadata"))]
update: bool,
#[arg(group("mode"), help_heading("Mode Options"), short, long, conflicts_with_all(["save", "get", "diff", "list", "update", "info", "status"]), requires("ids_or_tags"))]
#[arg(help("Delete items either by ID or by matching tags"))]
delete: bool,
#[arg(group("mode"), help_heading("Mode Options"), short, long, conflicts_with_all(["save", "get", "diff", "list", "update", "delete", "status"]), requires("ids_or_tags"))]
#[arg(help(
"Get an item either by it's ID or by a combination of matching tags and metatdata"
))]
info: bool,
#[arg(group("mode"), help_heading("Mode Options"), short('S'), long, conflicts_with_all(["save", "get", "diff", "list", "update", "delete", "info", "server"]))]
#[arg(help("Show status of directories and supported compression algorithms"))]
status: bool,
#[arg(group("mode"), help_heading("Mode Options"), long, conflicts_with_all(["save", "get", "diff", "list", "update", "delete", "info", "status"]))]
#[arg(help("Start REST HTTP server on specified address:port or socket path"))]
server: Option<String>,
}
/**
* Struct for item-specific arguments.
*/
#[derive(Parser, Debug, Clone)]
struct ItemArgs {
#[arg(help_heading("Item Options"), short, long, conflicts_with_all(["get", "delete", "status"]))]
#[arg(help(
"Set metadata for the item using the format KEY=[VALUE], the metadata will be removed if VALUE is not provided"
))]
meta: Vec<KeyValue>,
#[arg(help_heading("Item Options"), long, env("KEEP_DIGEST"))]
#[arg(help("Digest algorithm to use when saving items"))]
digest: Option<String>,
#[arg(help_heading("Item Options"), short, long, env("KEEP_COMPRESSION"))]
#[arg(help("Compression algorithm to use when saving items"))]
compression: Option<String>,
#[arg(help_heading("Item Options"), short('M'), long, env("KEEP_META_PLUGINS"))]
#[arg(help("Meta plugins to use when saving items"))]
meta_plugins: Vec<String>,
}
/**
* Struct for general options.
*/
#[derive(Parser, Debug, Default, Clone)]
struct OptionsArgs {
#[arg(long, env("KEEP_DIR"))]
#[arg(help("Specify the directory to use for storage"))]
dir: Option<PathBuf>,
#[arg(
long,
env("KEEP_LIST_FORMAT"),
default_value("id,time,size,tags,meta:hostname")
)]
#[arg(help("A comma separated list of columns to display with --list"))]
list_format: String,
#[arg(short('H'), long)]
#[arg(help("Display file sizes with units"))]
human_readable: bool,
#[arg(short, long, action = clap::ArgAction::Count, conflicts_with("quiet"))]
#[arg(help("Increase message verbosity, can be given more than once"))]
verbose: u8,
#[arg(short, long)]
#[arg(help("Do not show any messages"))]
quiet: bool,
#[arg(long, value_enum, default_value("table"))]
#[arg(help("Output format (only works with --info, --status, --list)"))]
output_format: Option<String>,
#[arg(long, env("KEEP_SERVER_PASSWORD"))]
#[arg(help("Password for server authentication (requires --server)"))]
server_password: Option<String>,
#[arg(long, help("Force output even when binary data would be sent to a TTY"))]
force: bool,
}
/**
* Enum representing the different modes of operation.
*/
#[derive(Debug, PartialEq)]
enum KeepModes {
Unknown,
Save,
Get,
Diff,
List,
Update,
Delete,
Info,
Status,
Server,
}
/**
* Struct for key-value pairs.
*/
#[derive(Debug, Clone)]
struct KeyValue {
key: String,
value: String,
}
impl FromStr for KeyValue {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Error> {
match s.split_once('=') {
Some(kv) => Ok(KeyValue {
key: kv.0.to_string(),
value: kv.1.to_string(),
}),
None => Err(anyhow!("Unable to parse key=value pair")),
}
}
}
/**
* Enum for representing either a number or a string.
*/
#[derive(Debug, Clone)]
enum NumberOrString {
Number(i64),
Str(String),
}
impl FromStr for NumberOrString {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(s.parse::<i64>()
.map(NumberOrString::Number)
.unwrap_or_else(|_| NumberOrString::Str(s.to_string())))
}
}
use keep::args::{Args, NumberOrString};
use keep::config::Settings;
use keep::db;
use keep::modes;
/**
* Main function to handle command-line arguments and execute the appropriate mode.
@@ -222,31 +21,112 @@ fn main() -> Result<(), Error> {
let proj_dirs = ProjectDirs::from("gt0.ca", "Andrew Phillips", "Keep");
let mut cmd = Args::command();
let mut args = Args::parse();
let args = Args::parse();
stderrlog::new()
.module(module_path!())
.quiet(args.options.quiet)
.verbosity(usize::from(args.options.verbose + 2))
//.timestamp(stderrlog::Timestamp::Second)
.init()
.unwrap();
// Validate arguments based on mode
if let Err(e) = args.validate() {
cmd.error(ErrorKind::ValueValidation, e).exit();
}
// Handle --generate-completion early (prints to stdout and exits)
if let Some(shell) = args.mode.generate_completion {
clap_complete::generate(shell, &mut Args::command(), "keep", &mut std::io::stdout());
std::process::exit(0);
}
let start = Instant::now();
let mut builder = env_logger::Builder::new();
let show_module = args.options.verbose >= 2;
builder.format(move |buf, record| {
let elapsed = start.elapsed();
let ts = format!("[{:>6}.{:03}]", elapsed.as_secs(), elapsed.subsec_millis());
if show_module {
writeln!(
buf,
"{} {:<5} {}: {}",
ts,
record.level(),
record.module_path().unwrap_or("?"),
record.args()
)
} else {
writeln!(buf, "{} {:<5} {}", ts, record.level(), record.args())
}
});
let max_level = if args.options.quiet {
LevelFilter::Off
} else {
match args.options.verbose {
0 => LevelFilter::Warn,
1 => LevelFilter::Debug,
_ => LevelFilter::Trace,
}
};
builder.filter_module("keep", max_level);
builder.init();
debug!("MAIN: Start");
// Determine default data directory
let default_dir = match proj_dirs {
Some(ref proj_dirs) => proj_dirs.data_dir().to_path_buf(),
None => return Err(anyhow!("Unable to determine data directory")),
};
// Create unified settings using the new config system
let settings = Settings::new(&args, default_dir)?;
debug!("MAIN: Loaded settings: {settings:?}");
let ids = &mut Vec::new();
let tags = &mut Vec::new();
// For --info, --get, --export, and --list modes, treat numeric strings as IDs
for v in args.ids_or_tags.iter() {
debug!("MAIN: Parsed value: {v:?}");
match v.clone() {
NumberOrString::Number(num) => ids.push(num),
NumberOrString::Str(str) => tags.push(str),
NumberOrString::Number(num) => {
debug!("MAIN: Adding to ids: {num}");
ids.push(num)
}
NumberOrString::Str(str) => {
// For --info, --get, --export, and --list, try to parse strings as numbers to treat them as IDs
if (args.mode.info || args.mode.get || args.mode.export || args.mode.list)
&& let Ok(num) = str.parse::<i64>()
{
debug!("MAIN: Adding parsed string to ids: {num}");
ids.push(num);
continue;
}
// If not a number, or not using --info/--get/--export/--list, treat as tag
debug!("MAIN: Adding to tags: {str}");
tags.push(str)
}
}
}
tags.sort();
tags.dedup();
/// Internal enum representing the parsed execution mode.
#[derive(PartialEq, Debug)]
enum KeepModes {
Unknown,
Save,
Get,
Diff,
List,
Delete,
Info,
Update,
Export,
Import,
Status,
StatusPlugins,
#[cfg(feature = "server")]
Server,
GenerateConfig,
}
let mut mode: KeepModes = KeepModes::Unknown;
if args.mode.save {
@@ -259,14 +139,27 @@ fn main() -> Result<(), Error> {
mode = KeepModes::List;
} else if args.mode.delete {
mode = KeepModes::Delete;
} else if args.mode.update {
mode = KeepModes::Update;
} else if args.mode.info {
mode = KeepModes::Info;
} else if args.mode.update {
mode = KeepModes::Update;
} else if args.mode.export {
mode = KeepModes::Export;
} else if args.mode.import.is_some() {
mode = KeepModes::Import;
} else if args.mode.status {
mode = KeepModes::Status;
} else if args.mode.server.is_some() {
mode = KeepModes::Server;
} else if args.mode.status_plugins {
mode = KeepModes::StatusPlugins;
}
#[cfg(feature = "server")]
{
if args.mode.server {
mode = KeepModes::Server;
}
}
if args.mode.generate_config {
mode = KeepModes::GenerateConfig;
}
if mode == KeepModes::Unknown {
@@ -278,90 +171,202 @@ fn main() -> Result<(), Error> {
}
// Validate output format usage
if let Some(output_format_str) = &args.options.output_format {
if output_format_str != "table" && mode != KeepModes::Info && mode != KeepModes::Status && mode != KeepModes::List {
cmd.error(
if let Some(output_format_str) = &settings.output_format
&& output_format_str != "table"
&& mode != KeepModes::Info
&& mode != KeepModes::Status
&& mode != KeepModes::StatusPlugins
&& mode != KeepModes::List
{
cmd.error(
ErrorKind::InvalidValue,
"--output-format can only be used with --info, --status, or --list modes"
"--output-format can only be used with --info, --status, --status-plugins, or --list modes"
).exit();
}
}
// Validate human-readable usage
if args.options.human_readable && mode != KeepModes::List && mode != KeepModes::Info {
if settings.human_readable && mode != KeepModes::List && mode != KeepModes::Info {
cmd.error(
ErrorKind::InvalidValue,
"--human-readable can only be used with --list and --info modes"
).exit();
"--human-readable can only be used with --list and --info modes",
)
.exit();
}
// Validate server password usage
if args.options.server_password.is_some() && mode != KeepModes::Server {
#[cfg(feature = "server")]
if settings.server_password().is_some() && mode != KeepModes::Server {
cmd.error(
ErrorKind::InvalidValue,
"--server-password can only be used with --server mode"
).exit();
"--server-password can only be used with --server mode",
)
.exit();
}
debug!("MAIN: args: {:?}", args);
debug!("MAIN: ids: {:?}", ids);
debug!("MAIN: tags: {:?}", tags);
debug!("MAIN: mode: {:?}", mode);
// Validate ids-only usage
if settings.ids_only && mode != KeepModes::List {
cmd.error(
ErrorKind::InvalidValue,
"--ids-only can only be used with --list mode",
)
.exit();
}
if args.options.dir.is_none() {
match proj_dirs {
Some(proj_dirs) => args.options.dir = Some(proj_dirs.data_dir().to_path_buf()),
None => return Err(anyhow!("Unable to determine data directory")),
debug!("MAIN: args: {args:?}");
debug!("MAIN: ids: {ids:?}");
debug!("MAIN: tags: {tags:?}");
debug!("MAIN: mode: {mode:?}");
debug!("MAIN: settings: {settings:?}");
// Parse filter chain early for better error reporting
let filter_chain = if let Some(filter_str) = &args.item.filters {
match keep::filter_plugin::parse_filter_string(filter_str) {
Ok(chain) => Some(chain),
Err(e) => {
cmd.error(
ErrorKind::InvalidValue,
format!("Invalid filter string: {e}"),
)
.exit();
}
}
} else {
None
};
// Check for client mode
#[cfg(feature = "client")]
{
if let Some(ref client_url) = settings.client_url {
let client = keep::client::KeepClient::new(
client_url,
settings.client_username.clone(),
settings.client_password.clone(),
settings.client_jwt.clone(),
)?;
return match mode {
KeepModes::Save => {
let metadata: std::collections::HashMap<String, String> = settings
.meta
.iter()
.filter_map(|(k, v)| v.as_ref().map(|val| (k.clone(), val.clone())))
.collect();
keep::modes::client::save::mode(&client, &mut cmd, &settings, tags, metadata)
}
KeepModes::Get => keep::modes::client::get::mode(
&client,
&mut cmd,
&settings,
ids,
tags,
filter_chain,
),
KeepModes::List => {
keep::modes::client::list::mode(&client, &mut cmd, &settings, ids, tags)
}
KeepModes::Delete => {
keep::modes::client::delete::mode(&client, &mut cmd, &settings, ids)
}
KeepModes::Info => {
keep::modes::client::info::mode(&client, &mut cmd, &settings, ids, tags)
}
KeepModes::Diff => {
keep::modes::client::diff::mode(&client, &mut cmd, &settings, ids)
}
KeepModes::Status => {
keep::modes::client::status::mode(&client, &mut cmd, &settings)
}
KeepModes::Update => {
keep::modes::client::update::mode(&client, &mut cmd, &settings, ids, tags)
}
KeepModes::Export => {
keep::modes::client::export::mode(&client, &mut cmd, &settings, ids, tags)
}
KeepModes::Import => {
let meta_file = args.mode.import.as_ref().unwrap();
keep::modes::client::import::mode(&client, &mut cmd, &settings, meta_file)
}
_ => {
cmd.error(
ErrorKind::InvalidValue,
format!("Mode {mode:?} is not supported in client mode"),
)
.exit();
}
};
}
}
// SAFETY: umask is thread-safe by POSIX spec, and we invoke it exactly once
// before any file operations to set a secure default mask. No other threads
// exist yet at this point in main(), so there is no data race.
unsafe {
libc::umask(0o077);
}
let data_path = args.options.dir.clone().unwrap();
let data_path = settings.dir.clone();
let mut db_path = data_path.clone();
db_path.push("keep-1.db");
debug!("MAIN: Data directory: {:?}", data_path);
debug!("MAIN: DB file: {:?}", db_path);
debug!("MAIN: Data directory: {data_path:?}");
debug!("MAIN: DB file: {db_path:?}");
fs::create_dir_all(data_path.clone()).context("Problem creating data directory")?;
debug!("MAIN: Data directory created or already exists");
// Ensure data directory exists
fs::create_dir_all(&data_path)
.with_context(|| format!("Unable to create data directory {data_path:?}"))?;
let mut conn = db::open(db_path.clone()).context("Problem opening database")?;
debug!("MAIN: DB opened successfully");
// Initialize database
let mut conn = db::open(db_path.clone())?;
match mode {
KeepModes::Save => {
crate::modes::save::mode_save(&mut cmd, &args, ids, tags, &mut conn, data_path)?
}
KeepModes::Get => {
crate::modes::get::mode_get(&mut cmd, &args, ids, tags, &mut conn, data_path)?
}
KeepModes::Diff => {
crate::modes::diff::mode_diff(&mut cmd, &args, ids, tags, &mut conn, data_path)?
modes::save::mode_save(&mut cmd, &settings, ids, tags, &mut conn, data_path)
}
KeepModes::Get => modes::get::mode_get(
&mut cmd,
&settings,
ids,
tags,
&mut conn,
data_path,
filter_chain,
),
KeepModes::Diff => modes::diff::mode_diff(&mut cmd, &args, &mut conn),
KeepModes::List => {
crate::modes::list::mode_list(&mut cmd, &args, ids, tags, &mut conn, data_path)?
modes::list::mode_list(&mut cmd, &settings, ids, tags, &mut conn, data_path)
}
KeepModes::Delete => modes::delete::mode_delete(
&mut cmd, &settings, &settings, ids, tags, &mut conn, data_path,
),
KeepModes::Info => {
modes::info::mode_info(&mut cmd, &settings, ids, tags, &mut conn, data_path)
}
KeepModes::Update => {
crate::modes::update::mode_update(&mut cmd, &args, ids, tags, &mut conn, data_path)?
modes::update::mode_update(&mut cmd, &settings, ids, tags, &mut conn, data_path)
}
KeepModes::Info => {
crate::modes::info::mode_info(&mut cmd, &args, ids, tags, &mut conn, data_path)?
KeepModes::Export => modes::export::mode_export(
&mut cmd,
&settings,
ids,
tags,
&mut conn,
data_path,
filter_chain,
),
KeepModes::Import => {
let meta_file = args.mode.import.as_ref().unwrap();
modes::import::mode_import(&mut cmd, &settings, meta_file, &mut conn, data_path)
}
KeepModes::Delete => {
crate::modes::delete::mode_delete(&mut cmd, &args, ids, tags, &mut conn, data_path)?
KeepModes::Status => modes::status::mode_status(&mut cmd, &settings, data_path, db_path),
KeepModes::StatusPlugins => {
modes::status_plugins::mode_status_plugins(&mut cmd, &settings, data_path, db_path)
}
KeepModes::Status => {
crate::modes::status::mode_status(&mut cmd, &args, data_path, db_path)?
#[cfg(feature = "server")]
KeepModes::Server => modes::server::mode_server(&mut cmd, &settings, &mut conn, data_path),
KeepModes::GenerateConfig => {
modes::generate_config::mode_generate_config(&mut cmd, &settings)
}
KeepModes::Server => {
crate::modes::server::mode_server(&mut cmd, &args, &mut conn, data_path)?
}
_ => todo!(),
KeepModes::Unknown => unreachable!(),
}
Ok(())
}

View File

@@ -1,86 +0,0 @@
use anyhow::Result;
use std::io;
use std::io::Write;
pub mod program;
pub mod digest;
pub mod system;
use crate::meta_plugin::program::MetaPluginProgram;
use crate::meta_plugin::digest::{DigestSha256MetaPlugin, ReadTimeMetaPlugin, ReadRateMetaPlugin};
use crate::meta_plugin::system::{CwdMetaPlugin, BinaryMetaPlugin, UidMetaPlugin, UserMetaPlugin, GidMetaPlugin, GroupMetaPlugin, ShellMetaPlugin, ShellPidMetaPlugin, KeepPidMetaPlugin, HostnameMetaPlugin, FullHostnameMetaPlugin};
#[derive(Debug, Eq, PartialEq, Clone, strum::EnumIter, strum::Display, strum::EnumString)]
#[strum(ascii_case_insensitive)]
pub enum MetaPluginType {
FileMagic,
FileMime,
FileEncoding,
LineCount,
WordCount,
Cwd,
Binary,
Uid,
User,
Gid,
Group,
Shell,
ShellPid,
KeepPid,
DigestSha256,
DigestMd5,
ReadTime,
ReadRate,
Hostname,
FullHostname,
}
pub trait MetaPlugin {
fn is_supported(&self) -> bool {
true
}
fn is_internal(&self) -> bool {
false
}
fn create(&self) -> Result<Box<dyn Write>>;
fn finalize(&mut self) -> io::Result<String>;
// Update the meta plugin with new data
fn update(&mut self, data: &[u8]);
fn meta_name(&mut self) -> String;
// Get program information for display in status
fn program_info(&self) -> Option<(&str, Vec<&str>)> {
None
}
}
pub fn get_meta_plugin(meta_plugin_type: MetaPluginType) -> Box<dyn MetaPlugin> {
match meta_plugin_type {
MetaPluginType::FileMagic => Box::new(MetaPluginProgram::new("file", vec!["-bE", "-"], "file_magic".to_string(), true)),
MetaPluginType::FileMime => Box::new(MetaPluginProgram::new("file", vec!["-b", "--mime-type", "-"], "file_mime".to_string(), true)),
MetaPluginType::FileEncoding => Box::new(MetaPluginProgram::new("file", vec!["-b", "--mime-encoding", "-"], "file_encoding".to_string(), true)),
MetaPluginType::LineCount => Box::new(MetaPluginProgram::new("wc", vec!["-l"], "line_count".to_string(), true)),
MetaPluginType::WordCount => Box::new(MetaPluginProgram::new("wc", vec!["-w"], "word_count".to_string(), true)),
MetaPluginType::Cwd => Box::new(CwdMetaPlugin::new()),
MetaPluginType::Binary => Box::new(BinaryMetaPlugin::new()),
MetaPluginType::Uid => Box::new(UidMetaPlugin::new()),
MetaPluginType::User => Box::new(UserMetaPlugin::new()),
MetaPluginType::Gid => Box::new(GidMetaPlugin::new()),
MetaPluginType::Group => Box::new(GroupMetaPlugin::new()),
MetaPluginType::Shell => Box::new(ShellMetaPlugin::new()),
MetaPluginType::ShellPid => Box::new(ShellPidMetaPlugin::new()),
MetaPluginType::KeepPid => Box::new(KeepPidMetaPlugin::new()),
MetaPluginType::DigestSha256 => Box::new(DigestSha256MetaPlugin::new()),
MetaPluginType::DigestMd5 => Box::new(MetaPluginProgram::new("md5sum", vec![], "digest_md5".to_string(), true)),
MetaPluginType::ReadTime => Box::new(ReadTimeMetaPlugin::new()),
MetaPluginType::ReadRate => Box::new(ReadRateMetaPlugin::new()),
MetaPluginType::Hostname => Box::new(HostnameMetaPlugin::new()),
MetaPluginType::FullHostname => Box::new(FullHostnameMetaPlugin::new()),
}
}

141
src/meta_plugin/cwd.rs Normal file
View File

@@ -0,0 +1,141 @@
use crate::meta_plugin::{MetaPlugin, MetaPluginType};
use std::env;
#[derive(Debug, Clone, Default)]
pub struct CwdMetaPlugin {
is_finalized: bool,
base: crate::meta_plugin::BaseMetaPlugin,
}
impl CwdMetaPlugin {
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> CwdMetaPlugin {
let mut base = crate::meta_plugin::BaseMetaPlugin::new();
// Set default outputs
let default_outputs = vec!["cwd".to_string()];
for output_name in default_outputs {
base.outputs
.insert(output_name.clone(), serde_yaml::Value::String(output_name));
}
// Apply provided options and outputs
if let Some(opts) = options {
for (key, value) in opts {
base.options.insert(key, value);
}
}
if let Some(outs) = outputs {
for (key, value) in outs {
base.outputs.insert(key, value);
}
}
CwdMetaPlugin {
is_finalized: false,
base,
}
}
}
impl MetaPlugin for CwdMetaPlugin {
fn is_finalized(&self) -> bool {
self.is_finalized
}
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn finalize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
// Mark as finalized
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
}
}
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::Cwd
}
fn initialize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
let cwd = match env::current_dir() {
Ok(path) => path.to_string_lossy().to_string(),
Err(_) => "unknown".to_string(),
};
// Use process_metadata_outputs to handle output mapping
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
"cwd",
serde_yaml::Value::String(cwd),
self.base.outputs(),
) {
metadata.push(meta_data);
}
crate::meta_plugin::MetaPluginResponse {
metadata,
is_finalized: false,
}
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
}
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_cwd_plugin() {
register_meta_plugin(MetaPluginType::Cwd, |options, outputs| {
Box::new(CwdMetaPlugin::new(options, outputs))
})
.expect("Failed to register CwdMetaPlugin");
}

View File

@@ -1,159 +1,284 @@
use anyhow::Result;
use sha2::{Digest, Sha256};
use std::io;
use crate::meta_plugin::{BaseMetaPlugin, MetaPlugin, MetaPluginType};
use md5;
use sha2::{Digest, Sha256, Sha512};
use std::io::Write;
use std::time::Instant;
use crate::meta_plugin::MetaPlugin;
#[derive(Debug, Clone, Default)]
pub struct DigestSha256MetaPlugin {
hasher: Sha256,
meta_name: String,
#[derive(Clone)]
enum Hasher {
Sha256(Sha256),
Md5(md5::Context),
Sha512(Sha512),
}
impl DigestSha256MetaPlugin {
pub fn new() -> DigestSha256MetaPlugin {
DigestSha256MetaPlugin {
hasher: Sha256::new(),
meta_name: "digest_sha256".to_string(),
impl Default for Hasher {
fn default() -> Self {
Hasher::Sha256(Sha256::default())
}
}
// Manual Debug implementation to avoid md5::Context not implementing Debug
impl std::fmt::Debug for Hasher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Hasher::Sha256(_) => write!(f, "Hasher::Sha256"),
Hasher::Md5(_) => write!(f, "Hasher::Md5"),
Hasher::Sha512(_) => write!(f, "Hasher::Sha512"),
}
}
}
impl MetaPlugin for DigestSha256MetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
// For meta plugins, we don't actually create a writer since we're buffering data internally
// This method is required by the trait but not used in the same way as digest engines
Ok(Box::new(DummyWriter))
}
fn finalize(&mut self) -> io::Result<String> {
let result = self.hasher.clone().finalize();
Ok(format!("{:x}", result))
}
impl Hasher {
fn update(&mut self, data: &[u8]) {
self.hasher.update(data);
match self {
Hasher::Sha256(hasher) => hasher.update(data),
Hasher::Md5(hasher) => {
hasher.consume(data);
}
Hasher::Sha512(hasher) => hasher.update(data),
}
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
// Dummy writer that implements Write but doesn't do anything
// This is needed to satisfy the MetaPlugin trait requirements
struct DummyWriter;
impl Write for DummyWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
Ok(buf.len())
fn finalize(&mut self) -> String {
match self {
Hasher::Sha256(hasher) => {
let result = std::mem::replace(hasher, Sha256::new()).finalize_reset();
format!("{result:x}")
}
Hasher::Md5(hasher) => {
let result = hasher.clone().compute();
format!("{result:x}")
}
Hasher::Sha512(hasher) => {
let result = std::mem::replace(hasher, Sha512::new()).finalize_reset();
format!("{result:x}")
}
}
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[derive(Debug, Clone, Default)]
pub struct ReadTimeMetaPlugin {
start_time: Option<Instant>,
meta_name: String,
}
impl ReadTimeMetaPlugin {
pub fn new() -> ReadTimeMetaPlugin {
ReadTimeMetaPlugin {
start_time: None,
meta_name: "read_time".to_string(),
fn output_name(&self) -> &'static str {
match self {
Hasher::Sha256(_) => "digest_sha256",
Hasher::Md5(_) => "digest_md5",
Hasher::Sha512(_) => "digest_sha512",
}
}
}
impl MetaPlugin for ReadTimeMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
// For meta plugins, we don't actually create a writer since we're buffering data internally
Ok(Box::new(DummyWriter))
}
fn finalize(&mut self) -> io::Result<String> {
if let Some(start_time) = self.start_time {
let duration = start_time.elapsed();
Ok(format!("{:.6}s", duration.as_secs_f64()))
} else {
Ok("0.000000s".to_string())
}
}
fn update(&mut self, _data: &[u8]) {
if self.start_time.is_none() {
self.start_time = Some(Instant::now());
}
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
#[derive(Debug, Default)]
pub struct DigestMetaPlugin {
hasher: Option<Hasher>,
is_finalized: bool,
base: BaseMetaPlugin,
}
#[derive(Debug, Clone, Default)]
pub struct ReadRateMetaPlugin {
start_time: Option<Instant>,
bytes_read: u64,
meta_name: String,
}
impl DigestMetaPlugin {
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> DigestMetaPlugin {
let mut base = BaseMetaPlugin::new();
impl ReadRateMetaPlugin {
pub fn new() -> ReadRateMetaPlugin {
ReadRateMetaPlugin {
start_time: None,
bytes_read: 0,
meta_name: "read_rate".to_string(),
// Apply provided options
if let Some(opts) = options {
for (key, value) in opts {
base.options.insert(key, value);
}
}
}
}
impl MetaPlugin for ReadRateMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
// For meta plugins, we don't actually create a writer since we're buffering data internally
Ok(Box::new(DummyWriter))
}
fn finalize(&mut self) -> io::Result<String> {
if let Some(start_time) = self.start_time {
let duration = start_time.elapsed();
if duration.as_secs_f64() > 0.0 {
let rate = self.bytes_read as f64 / duration.as_secs_f64();
Ok(format!("{:.0} B/s", rate))
// Determine the selected method
let method = if let Some(method_value) = base.options.get("method") {
if let Some(method_str) = method_value.as_str() {
match method_str {
"md5" => "md5",
"sha256" => "sha256",
"sha512" => "sha512",
_ => "sha256",
}
} else {
Ok("0 B/s".to_string())
"sha256"
}
} else {
Ok("0 B/s".to_string())
}
}
"sha256"
};
fn update(&mut self, data: &[u8]) {
if self.start_time.is_none() {
self.start_time = Some(Instant::now());
}
self.bytes_read += data.len() as u64;
}
// Initialize the hasher based on the method
let hasher = match method {
"md5" => Some(Hasher::Md5(md5::Context::new())),
"sha256" => Some(Hasher::Sha256(Sha256::new())),
"sha512" => Some(Hasher::Sha512(Sha512::new())),
_ => Some(Hasher::Sha256(Sha256::new())),
};
fn meta_name(&mut self) -> String {
self.meta_name.clone()
// Add the method to options so it shows up in the status
base.options.insert(
"method".to_string(),
serde_yaml::Value::String(method.to_string()),
);
// Set outputs based on the selected hash method
// Only the selected method's output should be enabled, others should be None
let all_outputs = vec!["digest_md5", "digest_sha256", "digest_sha512"];
for output_name in &all_outputs {
if output_name == &format!("digest_{method}") {
base.outputs.insert(
output_name.to_string(),
serde_yaml::Value::String(output_name.to_string()),
);
} else {
base.outputs
.insert(output_name.to_string(), serde_yaml::Value::Null);
}
}
// Apply provided outputs, but only for enabled outputs
if let Some(outs) = outputs {
for (key, value) in outs {
// Only update if the output is not disabled (not None)
if let Some(current_value) = base.outputs.get_mut(&key)
&& !current_value.is_null()
{
*current_value = value;
}
}
}
DigestMetaPlugin {
hasher,
is_finalized: false,
base,
}
}
}
impl MetaPlugin for DigestMetaPlugin {
fn is_finalized(&self) -> bool {
self.is_finalized
}
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn initialize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn finalize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
// Update outputs based on the selected hash method
if let Some(hasher) = &mut self.hasher {
let hash_value = hasher.finalize();
let output_name = hasher.output_name();
// Use process_metadata_outputs to handle output mapping
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
output_name,
serde_yaml::Value::String(hash_value),
self.base.outputs(),
) {
metadata.push(meta_data);
}
// Set all other digest outputs to None
let all_outputs = vec!["digest_md5", "digest_sha256", "digest_sha512"];
for output_name in all_outputs {
if output_name != hasher.output_name() {
self.base
.outputs
.insert(output_name.to_string(), serde_yaml::Value::Null);
}
}
}
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata,
is_finalized: true,
}
}
fn update(&mut self, data: &[u8]) -> crate::meta_plugin::MetaPluginResponse {
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
// Update the active hasher
if let Some(hasher) = &mut self.hasher {
hasher.update(data);
}
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::Digest
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
fn default_outputs(&self) -> Vec<String> {
vec![
"digest_md5".to_string(),
"digest_sha256".to_string(),
"digest_sha512".to_string(),
]
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
fn parallel_safe(&self) -> bool {
true
}
}
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_digest_plugin() {
register_meta_plugin(MetaPluginType::Digest, |options, outputs| {
Box::new(DigestMetaPlugin::new(options, outputs))
})
.expect("Failed to register DigestMetaPlugin");
}

256
src/meta_plugin/env.rs Normal file
View File

@@ -0,0 +1,256 @@
use super::{BaseMetaPlugin, MetaPlugin, MetaPluginType, process_metadata_outputs};
#[derive(Debug, Clone)]
/// Meta plugin that extracts environment variables prefixed with KEEP_META_ as metadata.
pub struct EnvMetaPlugin {
is_finalized: bool,
base: BaseMetaPlugin,
env_vars: Vec<(String, String)>,
}
impl EnvMetaPlugin {
/// Creates a new `EnvMetaPlugin` instance.
///
/// Collects environment variables starting with KEEP_META_ and sets up default output mappings.
///
/// # Arguments
///
/// * `_options` - Optional configuration options for the plugin (unused in this implementation).
/// * `outputs` - Optional output mappings for metadata (overrides defaults).
///
/// # Returns
///
/// A new instance of `EnvMetaPlugin`.
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> Self {
let mut env_vars = Vec::new();
let mut outputs_map = std::collections::HashMap::new();
// Use options from --meta-plugin JSON if provided and non-empty,
// otherwise fall back to KEEP_META_* environment variables.
let use_options = options.as_ref().map(|o| !o.is_empty()).unwrap_or(false);
if use_options {
let opts = options.as_ref().unwrap();
for (key, value) in opts {
let value_str = match value {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
_ => serde_yaml::to_string(value).unwrap_or_default(),
};
env_vars.push((key.clone(), value_str));
outputs_map.insert(key.clone(), serde_yaml::Value::String(key.clone()));
}
} else {
// Fall back to KEEP_META_* environment variables
for (key, value) in std::env::vars() {
if let Some(stripped_key) = key.strip_prefix("KEEP_META_") {
env_vars.push((stripped_key.to_string(), value));
outputs_map.insert(
stripped_key.to_string(),
serde_yaml::Value::String(stripped_key.to_string()),
);
}
}
}
// Override with provided outputs
if let Some(provided_outputs) = outputs {
for (key, value) in provided_outputs {
outputs_map.insert(key, value);
}
}
let mut base = BaseMetaPlugin::new();
base.outputs = outputs_map;
EnvMetaPlugin {
is_finalized: false,
base,
env_vars,
}
}
}
impl MetaPlugin for EnvMetaPlugin {
/// Returns the type of this meta plugin.
///
/// # Returns
///
/// `MetaPluginType::Env`.
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::Env
}
/// Checks if the plugin has been finalized.
///
/// # Returns
///
/// `true` if finalized, `false` otherwise.
fn is_finalized(&self) -> bool {
self.is_finalized
}
/// Sets the finalized state of the plugin.
///
/// # Arguments
///
/// * `finalized` - The new finalized state.
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
/// Initializes the plugin, processing environment variables.
///
/// Processes all KEEP_META_* variables and generates metadata using output mappings.
///
/// # Returns
///
/// A `MetaPluginResponse` with environment metadata and finalized state set to `true`.
fn initialize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
// Process all collected environment variables
let mut metadata = Vec::new();
for (name, value) in &self.env_vars {
if let Some(meta_data) = process_metadata_outputs(
name,
serde_yaml::Value::String(value.clone()),
self.base.outputs(),
) {
metadata.push(meta_data);
}
}
// Mark as finalized since this plugin only needs to run once
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata,
is_finalized: true,
}
}
/// Updates the plugin with new data (unused in this implementation).
///
/// This plugin does not process streaming data; returns empty response.
///
/// # Arguments
///
/// * `_data` - The data chunk (unused).
///
/// # Returns
///
/// A `MetaPluginResponse` with empty metadata and current finalized state.
fn update(&mut self, _data: &[u8]) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process more data
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
/// Finalizes the plugin, calling initialize if not already done.
///
/// Ensures environment metadata is processed if not previously initialized.
///
/// # Returns
///
/// A `MetaPluginResponse` with environment metadata if not finalized, or empty if already done.
fn finalize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If not already finalized, we can call initialize
if !self.is_finalized {
return self.initialize();
}
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
}
}
/// Returns a reference to the outputs mapping.
///
/// # Returns
///
/// A reference to the `HashMap` of outputs.
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
/// Returns a mutable reference to the outputs mapping.
///
/// # Returns
///
/// A mutable reference to the `HashMap` of outputs.
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
/// Returns the default output names based on collected env vars.
///
/// # Returns
///
/// A vector of environment variable names (stripped of KEEP_META_ prefix).
fn default_outputs(&self) -> Vec<String> {
self.env_vars.iter().map(|(name, _)| name.clone()).collect()
}
/// Returns a reference to the options mapping (empty for this plugin).
///
/// This plugin has no configurable options.
///
/// # Returns
///
/// An empty `HashMap`.
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
/// Returns a mutable reference to the options mapping.
///
/// # Panics
///
/// Panics with "options_mut() not implemented for EnvMetaPlugin".
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
}
use crate::meta_plugin::register_meta_plugin;
/// Registers the EnvMetaPlugin with the global registry at module initialization.
#[ctor::ctor]
fn register_env_plugin() {
register_meta_plugin(MetaPluginType::Env, |options, outputs| {
Box::new(EnvMetaPlugin::new(options, outputs))
})
.expect("Failed to register EnvMetaPlugin");
}

336
src/meta_plugin/exec.rs Normal file
View File

@@ -0,0 +1,336 @@
use log::*;
use std::io::{self, Write};
use std::process::{Child, Command, Stdio};
use which::which;
use crate::meta_plugin::{BaseMetaPlugin, MetaPlugin, MetaPluginResponse, MetaPluginType};
/// External program execution meta plugin.
///
/// This plugin executes a specified external command during item save operations,
/// capturing its output as metadata. It supports piping input data to the command's stdin
/// and processing stdout. Useful for dynamic metadata generation via shell commands.
///
/// # Examples
///
/// Configured via options like `command: "date"`, the plugin runs `date` and captures output as metadata.
pub struct MetaPluginExec {
pub program: String,
pub args: Vec<String>,
pub supported: bool,
pub split_whitespace: bool,
process: Option<Child>,
writer: Option<Box<dyn Write + Send>>,
result: Option<String>,
base: BaseMetaPlugin,
}
// Manual Debug implementation because Box<dyn Write> doesn't implement Debug
/// Custom Debug implementation for MetaPluginExec.
///
/// Obfuscates the writer field since Box<dyn Write> does not implement Debug.
impl std::fmt::Debug for MetaPluginExec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MetaPluginExec")
.field("program", &self.program)
.field("args", &self.args)
.field("supported", &self.supported)
.field("split_whitespace", &self.split_whitespace)
.field("process", &self.process)
.field("writer", &self.writer.as_ref().map(|_| "Box<dyn Write>"))
.field("result", &self.result)
.field("base", &self.base)
.finish()
}
}
impl MetaPluginExec {
/// Creates a new MetaPluginExec instance.
///
/// Validates the program availability using `which` and initializes outputs and options.
/// The meta_name determines the default output key for captured command output.
///
/// # Arguments
///
/// * `program` - The executable name or path to run.
/// * `args` - Slice of arguments to pass to the program.
/// * `meta_name` - Name for the metadata output key.
/// * `split_whitespace` - If true, takes the first whitespace-separated word from output; otherwise, trims full output.
/// * `_options` - Optional configuration options (currently unused beyond passing through).
/// * `outputs` - Optional output mappings to override defaults.
///
/// # Returns
///
/// * `MetaPluginExec` - New plugin instance, with `supported` set based on program availability.
///
/// # Examples
///
/// ```
/// # use keep::meta_plugin::MetaPluginExec;
/// let plugin = MetaPluginExec::new("date", &[], "date_output".to_string(), false, None, None);
/// ```
pub fn new(
program: &str,
args: &[String],
meta_name: String,
split_whitespace: bool,
_options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> MetaPluginExec {
let supported = which(program).is_ok();
let mut base = BaseMetaPlugin::new();
// Set default output
let default_outputs = &[meta_name.as_str()];
base.initialize_plugin(default_outputs, &_options, &outputs);
MetaPluginExec {
program: program.to_string(),
args: args.to_vec(),
supported,
split_whitespace,
process: None,
writer: None,
result: None,
base,
}
}
/// Starts the external process if not already running.
///
/// Spawns the command with piped stdin/stdout and stores the child process and writer.
///
/// # Returns
///
/// * `MetaPluginResponse` - Empty response, initializes the process.
fn start_process(&mut self) -> MetaPluginResponse {
if self.process.is_some() {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
};
}
if !self.supported {
debug!(
"META: Exec plugin: program '{}' not supported",
self.program
);
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut cmd = Command::new(&self.program);
cmd.args(&self.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
match cmd.spawn() {
Ok(mut child) => {
let stdin = match child.stdin.take() {
Some(s) => s,
None => {
error!(
"META: Exec plugin: failed to capture stdin for '{}'",
self.program
);
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
};
self.writer = Some(Box::new(stdin));
self.process = Some(child);
debug!("META: Exec plugin: started process for '{}'", self.program);
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
Err(e) => {
error!(
"META: Exec plugin: failed to start '{}': {}",
self.program, e
);
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
}
}
}
}
}
impl MetaPlugin for MetaPluginExec {
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::Exec
}
fn is_supported(&self) -> bool {
self.supported
}
fn is_internal(&self) -> bool {
false
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn initialize(&mut self) -> MetaPluginResponse {
self.start_process()
}
fn update(&mut self, data: &[u8]) -> MetaPluginResponse {
if let Some(writer) = self.writer.as_mut()
&& let Err(e) = writer.write_all(data)
{
error!("META: Exec plugin: failed to write to stdin: {e}");
}
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn finalize(&mut self) -> MetaPluginResponse {
let mut metadata = Vec::new();
// Close stdin if writer exists
drop(self.writer.take());
// Wait for process to complete and capture output
if let Some(child) = self.process.take() {
match child.wait_with_output() {
Ok(output) => {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let result = if self.split_whitespace {
stdout
.split_whitespace()
.next()
.unwrap_or(&stdout)
.to_string()
} else {
stdout.trim().to_string()
};
self.result = Some(result.clone());
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
self.base
.outputs()
.keys()
.next()
.unwrap_or(&"exec".to_string()),
serde_yaml::Value::String(result),
self.base.outputs(),
) {
metadata.push(meta_data);
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
error!("META: Exec plugin: command failed: {stderr}");
}
}
Err(e) => {
error!("META: Exec plugin: failed to wait on process: {e}");
}
}
}
MetaPluginResponse {
metadata,
is_finalized: true,
}
}
fn program_info(&self) -> Option<(&str, Vec<&str>)> {
let args_str: Vec<&str> = self.args.iter().map(|s| s.as_str()).collect();
Some((&self.program, args_str))
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
&self.base.outputs
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(&mut self.base.outputs)
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
&self.base.options
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(&mut self.base.options)
}
fn default_outputs(&self) -> Vec<String> {
vec!["exec".to_string()]
}
fn parallel_safe(&self) -> bool {
true
}
}
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_exec_plugin() {
register_meta_plugin(MetaPluginType::Exec, |options, outputs| {
// Parse command from options for registration
let mut program_name = String::new();
let mut args = Vec::new();
let mut meta_name = "exec".to_string();
let mut split_whitespace = false;
if let Some(opts) = &options {
if let Some(command_value) = opts.get("command")
&& let Some(command_str) = command_value.as_str()
{
let parts: Vec<&str> = command_str.split_whitespace().collect();
if !parts.is_empty() {
program_name = parts[0].to_string();
args = parts[1..].iter().map(|s| s.to_string()).collect();
}
}
if let Some(split_value) = opts.get("split_whitespace")
&& let Some(split_bool) = split_value.as_bool()
{
split_whitespace = split_bool;
}
if let Some(name_value) = opts.get("name")
&& let Some(name_str) = name_value.as_str()
{
meta_name = name_str.to_string();
}
}
Box::new(MetaPluginExec::new(
&program_name,
&args,
meta_name,
split_whitespace,
options,
outputs,
))
})
.expect("Failed to register ExecMetaPlugin");
}

419
src/meta_plugin/hostname.rs Normal file
View File

@@ -0,0 +1,419 @@
use crate::meta_plugin::{BaseMetaPlugin, MetaPlugin, MetaPluginType};
use smart_default::SmartDefault;
#[derive(Debug, Clone, SmartDefault)]
pub struct HostnameMetaPlugin {
#[default = false]
is_finalized: bool,
base: BaseMetaPlugin,
}
impl HostnameMetaPlugin {
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> HostnameMetaPlugin {
let mut base = BaseMetaPlugin::new();
// Set default outputs
let default_outputs = &["hostname", "hostname_full", "hostname_short"];
base.initialize_plugin(default_outputs, &options, &outputs);
// Start with default options - hostname is now boolean only
base.options
.insert("hostname".to_string(), serde_yaml::Value::Bool(true));
base.options
.insert("hostname_full".to_string(), serde_yaml::Value::Bool(true));
base.options
.insert("hostname_short".to_string(), serde_yaml::Value::Bool(true));
// Override with provided options
if let Some(opts) = &options {
for (key, value) in opts {
// Convert string "true"/"false" to boolean for hostname option
if key == "hostname"
&& let serde_yaml::Value::String(s) = value
{
if s == "false" {
base.options
.insert(key.clone(), serde_yaml::Value::Bool(false));
continue;
} else if s == "true" {
base.options
.insert(key.clone(), serde_yaml::Value::Bool(true));
continue;
}
}
base.options.insert(key.clone(), value.clone());
}
}
// Determine which outputs are enabled based on options
let hostname_enabled = base
.options
.get("hostname")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let hostname_full_enabled = base
.options
.get("hostname_full")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let hostname_short_enabled = base
.options
.get("hostname_short")
.and_then(|v| v.as_bool())
.unwrap_or(true);
// Start with default outputs, setting disabled ones to None
let mut final_outputs = std::collections::HashMap::new();
// Handle hostname output
if hostname_enabled {
final_outputs.insert(
"hostname".to_string(),
serde_yaml::Value::String("hostname".to_string()),
);
} else {
final_outputs.insert("hostname".to_string(), serde_yaml::Value::Null);
}
// Handle hostname_full output
if hostname_full_enabled {
final_outputs.insert(
"hostname_full".to_string(),
serde_yaml::Value::String("hostname_full".to_string()),
);
} else {
final_outputs.insert("hostname_full".to_string(), serde_yaml::Value::Null);
}
// Handle hostname_short output
if hostname_short_enabled {
final_outputs.insert(
"hostname_short".to_string(),
serde_yaml::Value::String("hostname_short".to_string()),
);
} else {
final_outputs.insert("hostname_short".to_string(), serde_yaml::Value::Null);
}
// Override with provided outputs, but only if they're enabled
if let Some(outs) = &outputs {
for (key, value) in outs {
// Only add if the output is enabled
match key.as_str() {
"hostname" => {
if hostname_enabled {
final_outputs.insert(key.clone(), value.clone());
}
}
"hostname_full" => {
if hostname_full_enabled {
final_outputs.insert(key.clone(), value.clone());
}
}
"hostname_short" => {
if hostname_short_enabled {
final_outputs.insert(key.clone(), value.clone());
}
}
_ => {
final_outputs.insert(key.clone(), value.clone());
}
}
}
}
base.outputs = final_outputs;
HostnameMetaPlugin {
is_finalized: false,
base,
}
}
fn get_hostname(&self) -> String {
// First get the short hostname
let short_hostname = match gethostname::gethostname().into_string() {
Ok(hostname) => hostname,
Err(_) => return "unknown".to_string(),
};
// First try DNS resolution for both IPv4 and IPv6 addresses
// lookup_host should handle both A and AAAA records
if let Ok(addrs_iter) = dns_lookup::lookup_host(&short_hostname) {
// Collect addresses into a Vec to be able to use first()
let addrs: Vec<std::net::IpAddr> = addrs_iter.collect();
// Try each address (both IPv4 and IPv6)
for addr in &addrs {
// Convert to IpAddr for lookup_addr
let ip_addr = match addr {
std::net::IpAddr::V4(ipv4) => std::net::IpAddr::V4(*ipv4),
std::net::IpAddr::V6(ipv6) => std::net::IpAddr::V6(*ipv6),
};
// Perform reverse lookup for each address
match dns_lookup::lookup_addr(&ip_addr) {
Ok(full_hostname) => {
// Only use if it's different from the short hostname and looks like a FQDN
if full_hostname != short_hostname && full_hostname.contains('.') {
return full_hostname;
}
}
Err(_) => continue,
}
}
// If no reverse lookup worked, but we have addresses, try to construct FQDN
// from the first address's domain if the short hostname is part of a domain
if let Some(_first_addr) = addrs.first() {
// For local addresses, we might not get a reverse lookup, so try to infer
// from the system's domain name
if let Ok(domain) = std::process::Command::new("domainname").output()
&& domain.status.success()
{
let domain_str = String::from_utf8_lossy(&domain.stdout).trim().to_string();
if !domain_str.is_empty() && domain_str != "(none)" {
return format!("{short_hostname}.{domain_str}");
}
}
}
}
// Fallback: try to get the FQDN using the system's hostname resolution
// This should give us the full hostname if configured
if let Ok(full_hostname) = std::process::Command::new("hostname").arg("-f").output()
&& full_hostname.status.success()
{
let full_hostname_str = String::from_utf8_lossy(&full_hostname.stdout)
.trim()
.to_string();
if !full_hostname_str.is_empty() && full_hostname_str != short_hostname {
return full_hostname_str;
}
}
// Final fallback: return the short hostname
short_hostname
}
}
impl MetaPlugin for HostnameMetaPlugin {
fn is_finalized(&self) -> bool {
self.is_finalized
}
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn finalize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
// Mark as finalized
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
}
}
fn update(&mut self, _data: &[u8]) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process more data
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::Hostname
}
fn initialize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
// Get the full hostname
let full_hostname = self.get_hostname();
let short_hostname = full_hostname
.split('.')
.next()
.unwrap_or(&full_hostname)
.to_string();
// Determine which hostnames to include based on options
let hostname_enabled = self
.base
.options
.get("hostname")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let hostname_full_enabled = self
.base
.options
.get("hostname_full")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let hostname_short_enabled = self
.base
.options
.get("hostname_short")
.and_then(|v| v.as_bool())
.unwrap_or(true);
// Always use gethostname() for the 'hostname' output when enabled
let hostname_value = if hostname_enabled {
gethostname::gethostname()
.into_string()
.unwrap_or_else(|_| "unknown".to_string())
} else {
String::new()
};
// Prepare metadata to return
let mut metadata = Vec::new();
// Add enabled metadata to the response using process_metadata_outputs
if hostname_enabled
&& let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
"hostname",
serde_yaml::Value::String(hostname_value.clone()),
self.base.outputs(),
)
{
metadata.push(meta_data);
}
if hostname_full_enabled
&& let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
"hostname_full",
serde_yaml::Value::String(full_hostname.clone()),
self.base.outputs(),
)
{
metadata.push(meta_data);
}
if hostname_short_enabled
&& let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
"hostname_short",
serde_yaml::Value::String(short_hostname.clone()),
self.base.outputs(),
)
{
metadata.push(meta_data);
}
// Update outputs based on enabled status
// Handle hostname output
if hostname_enabled {
if let Some(output_value) = self.base.outputs_mut().get_mut("hostname") {
*output_value = serde_yaml::Value::String(hostname_value);
}
} else {
self.base
.outputs_mut()
.insert("hostname".to_string(), serde_yaml::Value::Null);
}
// Handle hostname_full output
if hostname_full_enabled {
if let Some(output_value) = self.base.outputs_mut().get_mut("hostname_full") {
*output_value = serde_yaml::Value::String(full_hostname);
}
} else {
self.base
.outputs_mut()
.insert("hostname_full".to_string(), serde_yaml::Value::Null);
}
// Handle hostname_short output
if hostname_short_enabled {
if let Some(output_value) = self.base.outputs_mut().get_mut("hostname_short") {
*output_value = serde_yaml::Value::String(short_hostname);
}
} else {
self.base
.outputs_mut()
.insert("hostname_short".to_string(), serde_yaml::Value::Null);
}
// Mark as finalized since this plugin only needs to run once
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata,
is_finalized: true,
}
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
fn default_outputs(&self) -> Vec<String> {
vec![
"hostname".to_string(),
"hostname_full".to_string(),
"hostname_short".to_string(),
]
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
}
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_hostname_plugin() {
register_meta_plugin(MetaPluginType::Hostname, |options, outputs| {
Box::new(HostnameMetaPlugin::new(options, outputs))
})
.expect("Failed to register HostnameMetaPlugin");
}

View File

@@ -0,0 +1,177 @@
use crate::common::PIPESIZE;
use crate::meta_plugin::{
BaseMetaPlugin, MetaPlugin, MetaPluginResponse, MetaPluginType, process_metadata_outputs,
register_meta_plugin,
};
#[derive(Debug, Default)]
pub struct InferMetaPlugin {
buffer: Vec<u8>,
max_buffer_size: usize,
is_finalized: bool,
base: BaseMetaPlugin,
}
impl InferMetaPlugin {
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> InferMetaPlugin {
let mut base = BaseMetaPlugin::new();
if let Some(opts) = options {
for (key, value) in opts {
base.options.insert(key, value);
}
}
let max_buffer_size = base
.options
.get("max_buffer_size")
.and_then(|v| v.as_u64())
.unwrap_or(PIPESIZE as u64) as usize;
base.outputs.insert(
"infer_mime_type".to_string(),
serde_yaml::Value::String("infer_mime_type".to_string()),
);
if let Some(outs) = outputs {
for (key, value) in outs {
base.outputs.insert(key, value);
}
}
InferMetaPlugin {
buffer: Vec::new(),
max_buffer_size,
is_finalized: false,
base,
}
}
}
impl MetaPlugin for InferMetaPlugin {
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::Infer
}
fn is_finalized(&self) -> bool {
self.is_finalized
}
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn update(&mut self, data: &[u8]) -> MetaPluginResponse {
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let remaining = self.max_buffer_size.saturating_sub(self.buffer.len());
let to_add = &data[..data.len().min(remaining)];
self.buffer.extend_from_slice(to_add);
if self.buffer.len() >= self.max_buffer_size {
let mime_type = infer::get(&self.buffer)
.map(|kind| kind.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
self.is_finalized = true;
let metadata = process_metadata_outputs(
"infer_mime_type",
serde_yaml::Value::String(mime_type),
self.base.outputs(),
)
.map(|m| vec![m])
.unwrap_or_default();
return MetaPluginResponse {
metadata,
is_finalized: true,
};
}
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn finalize(&mut self) -> MetaPluginResponse {
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mime_type = infer::get(&self.buffer)
.map(|kind| kind.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
self.is_finalized = true;
let metadata = process_metadata_outputs(
"infer_mime_type",
serde_yaml::Value::String(mime_type),
self.base.outputs(),
)
.map(|m| vec![m])
.unwrap_or_default();
MetaPluginResponse {
metadata,
is_finalized: true,
}
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
fn default_outputs(&self) -> Vec<String> {
vec!["infer_mime_type".to_string()]
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
fn parallel_safe(&self) -> bool {
true
}
}
#[ctor::ctor]
fn register_infer_plugin() {
register_meta_plugin(MetaPluginType::Infer, |options, outputs| {
Box::new(InferMetaPlugin::new(options, outputs))
})
.expect("Failed to register InferMetaPlugin");
}

217
src/meta_plugin/keep_pid.rs Normal file
View File

@@ -0,0 +1,217 @@
use crate::meta_plugin::{BaseMetaPlugin, MetaPlugin, MetaPluginType};
use std::process;
#[derive(Debug, Clone, Default)]
pub struct KeepPidMetaPlugin {
is_finalized: bool,
base: BaseMetaPlugin,
}
impl KeepPidMetaPlugin {
/// Creates a new `KeepPidMetaPlugin` instance.
///
/// # Arguments
///
/// * `_options` - Optional configuration options for the plugin (unused in this implementation).
/// * `outputs` - Optional output mappings for metadata.
///
/// # Returns
///
/// A new instance of `KeepPidMetaPlugin`.
pub fn new(
_options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> KeepPidMetaPlugin {
let mut base = BaseMetaPlugin::new();
// Set default outputs
let default_outputs = &["keep_pid"];
base.initialize_plugin(default_outputs, &_options, &outputs);
KeepPidMetaPlugin {
is_finalized: false,
base,
}
}
}
impl MetaPlugin for KeepPidMetaPlugin {
/// Checks if the plugin has been finalized.
///
/// # Returns
///
/// `true` if finalized, `false` otherwise.
fn is_finalized(&self) -> bool {
self.is_finalized
}
/// Sets the finalized state of the plugin.
///
/// # Arguments
///
/// * `finalized` - The new finalized state.
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
/// Finalizes the plugin, processing any remaining data if needed.
///
/// # Returns
///
/// A `MetaPluginResponse` with empty metadata and finalized state set to `true`.
fn finalize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
// Mark as finalized
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
}
}
/// Updates the plugin with new data chunk.
///
/// # Arguments
///
/// * `_data` - The data chunk (unused in this implementation).
///
/// # Returns
///
/// A `MetaPluginResponse` with empty metadata and finalized state.
fn update(&mut self, _data: &[u8]) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process more data
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
/// Returns the type of this meta plugin.
///
/// # Returns
///
/// `MetaPluginType::KeepPid`.
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::KeepPid
}
/// Initializes the plugin and captures the process PID.
///
/// Retrieves the current process ID and adds it to metadata.
/// Marks the plugin as finalized after one run.
///
/// # Returns
///
/// * `MetaPluginResponse` - Response with PID metadata and finalized state.
fn initialize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
let pid = process::id().to_string();
// Use process_metadata_outputs to handle output mapping
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
"keep_pid",
serde_yaml::Value::String(pid),
self.base.outputs(),
) {
metadata.push(meta_data);
}
// Mark as finalized since this plugin only needs to run once
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata,
is_finalized: true,
}
}
/// Returns a reference to the outputs mapping.
///
/// # Returns
///
/// A reference to the `HashMap` of outputs.
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
/// Returns a mutable reference to the outputs mapping.
///
/// # Returns
///
/// A mutable reference to the `HashMap` of outputs.
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
/// Returns the default output names for this plugin.
///
/// # Returns
///
/// Vector containing "keep_pid".
fn default_outputs(&self) -> Vec<String> {
vec!["keep_pid".to_string()]
}
/// Returns a reference to the options mapping.
///
/// # Returns
///
/// A reference to the `HashMap` of options.
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
/// Returns a mutable reference to the options mapping.
///
/// # Returns
///
/// A mutable reference to the `HashMap` of options.
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
}
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_keep_pid_plugin() {
register_meta_plugin(MetaPluginType::KeepPid, |options, outputs| {
Box::new(KeepPidMetaPlugin::new(options, outputs))
})
.expect("Failed to register KeepPidMetaPlugin");
}

View File

@@ -0,0 +1,455 @@
#[cfg(feature = "meta_magic")]
use magic::{Cookie, CookieFlags};
#[cfg(not(feature = "meta_magic"))]
use std::process::{Command, Stdio};
use std::io::{self, Write};
use std::path::Path;
use crate::meta_plugin::{
BaseMetaPlugin, MetaData, MetaPlugin, MetaPluginResponse, MetaPluginType,
process_metadata_outputs,
};
// Thread-local libmagic cookie, lazily initialized on first access per thread.
// Each thread gets its own independent Cookie instance. Libmagic documents that
// separate cookies can be used from different threads concurrently without
// synchronization. Using thread_local! avoids unsafe impl Send since the
// storage is inherently !Send.
#[cfg(feature = "meta_magic")]
thread_local! {
static MAGIC_COOKIE: std::cell::RefCell<Option<Cookie>> = const { std::cell::RefCell::new(None) };
}
#[cfg(feature = "meta_magic")]
#[derive(Debug)]
pub struct MagicFileMetaPluginImpl {
buffer: Vec<u8>,
max_buffer_size: usize,
is_finalized: bool,
base: BaseMetaPlugin,
}
#[cfg(feature = "meta_magic")]
impl MagicFileMetaPluginImpl {
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> MagicFileMetaPluginImpl {
let mut base = BaseMetaPlugin::new();
// Set default outputs
let default_outputs = &["mime_type", "mime_encoding", "file_type"];
base.initialize_plugin(default_outputs, &options, &outputs);
// Get max_buffer_size from options, default to PIPESIZE
let max_buffer_size = base
.options
.get("max_buffer_size")
.and_then(|v| v.as_u64())
.unwrap_or(crate::common::PIPESIZE as u64) as usize;
MagicFileMetaPluginImpl {
buffer: Vec::new(),
max_buffer_size,
is_finalized: false,
base,
}
}
fn get_magic_result(&self, flags: CookieFlags) -> io::Result<String> {
MAGIC_COOKIE.with(|cell| {
// Lazy init: create cookie on first access per thread
{
let mut opt = cell.borrow_mut();
if opt.is_none() {
let cookie = Cookie::open(CookieFlags::default())
.map_err(|e| io::Error::other(format!("Failed to open magic: {e}")))?;
cookie.load(&[] as &[&Path]).map_err(|e| {
io::Error::other(format!("Failed to load magic database: {e}"))
})?;
*opt = Some(cookie);
}
}
let cookie_ref = cell.borrow();
let cookie = cookie_ref.as_ref().expect("cookie initialized above");
cookie
.set_flags(flags)
.map_err(|e| io::Error::other(format!("Failed to set magic flags: {e}")))?;
let result = cookie
.buffer(&self.buffer)
.map_err(|e| io::Error::other(format!("Failed to analyze buffer: {e}")))?;
Ok(result.trim().to_string())
})
}
fn process_magic_types(&self) -> Vec<MetaData> {
let mut metadata = Vec::new();
let types_to_process = [
("mime_type", CookieFlags::MIME_TYPE),
("mime_encoding", CookieFlags::MIME_ENCODING),
("file_type", CookieFlags::empty()),
];
for (name, flags) in types_to_process.iter() {
if let Ok(result) = self.get_magic_result(*flags)
&& !result.is_empty()
&& let Some(meta_data) = process_metadata_outputs(
name,
serde_yaml::Value::String(result),
self.base.outputs(),
)
{
metadata.push(meta_data);
}
}
metadata
}
}
#[cfg(feature = "meta_magic")]
impl MetaPlugin for MagicFileMetaPluginImpl {
fn is_finalized(&self) -> bool {
self.is_finalized
}
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn initialize(&mut self) -> MetaPluginResponse {
// Cookie is lazily initialized in the thread-local on first use.
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn update(&mut self, data: &[u8]) -> MetaPluginResponse {
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let remaining_capacity = self.max_buffer_size.saturating_sub(self.buffer.len());
if remaining_capacity > 0 {
let bytes_to_copy = std::cmp::min(data.len(), remaining_capacity);
self.buffer.extend_from_slice(&data[..bytes_to_copy]);
if self.buffer.len() >= self.max_buffer_size {
let metadata = self.process_magic_types();
self.is_finalized = true;
return MetaPluginResponse {
metadata,
is_finalized: true,
};
}
}
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn finalize(&mut self) -> MetaPluginResponse {
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let metadata = self.process_magic_types();
self.is_finalized = true;
MetaPluginResponse {
metadata,
is_finalized: true,
}
}
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::MagicFile
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
fn default_outputs(&self) -> Vec<String> {
vec![
"mime_type".to_string(),
"mime_encoding".to_string(),
"file_type".to_string(),
]
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
fn parallel_safe(&self) -> bool {
true
}
}
#[cfg(feature = "meta_magic")]
pub use MagicFileMetaPluginImpl as MagicFileMetaPlugin;
#[cfg(not(feature = "meta_magic"))]
#[derive(Debug)]
pub struct FallbackMagicFileMetaPlugin {
buffer: Vec<u8>,
max_buffer_size: usize,
is_finalized: bool,
base: BaseMetaPlugin,
}
#[cfg(not(feature = "meta_magic"))]
impl FallbackMagicFileMetaPlugin {
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> Self {
let mut base = BaseMetaPlugin::new();
let default_outputs = &["mime_type", "mime_encoding", "file_type"];
base.initialize_plugin(default_outputs, &options, &outputs);
let max_buffer_size = base
.options
.get("max_buffer_size")
.and_then(|v| v.as_u64())
.unwrap_or(crate::common::PIPESIZE as u64) as usize;
Self {
buffer: Vec::new(),
max_buffer_size,
is_finalized: false,
base,
}
}
fn run_file_command(&self, args: &[&str]) -> Option<String> {
let output = Command::new("file")
.args(args)
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.and_then(|mut child| {
if let Some(mut stdin) = child.stdin.take() {
if stdin.write_all(&self.buffer).is_err() {
// Ignore write error; child will see EOF and likely fail
// the file detection, returning no output.
}
}
child.wait_with_output()
});
output
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn detect_type(&self) -> Vec<MetaData> {
let mut metadata = Vec::new();
// Get mime_type and mime_encoding via --mime
if let Some(mime_line) = self.run_file_command(&["--brief", "--mime"]) {
// Format: "text/plain; charset=us-ascii"
if let Some((mime_type, rest)) = mime_line.split_once(';') {
let mime_type = mime_type.trim().to_string();
let mime_encoding = rest
.trim()
.strip_prefix("charset=")
.unwrap_or("binary")
.to_string();
if let Some(meta_data) = process_metadata_outputs(
"mime_type",
serde_yaml::Value::String(mime_type),
self.base.outputs(),
) {
metadata.push(meta_data);
}
if let Some(meta_data) = process_metadata_outputs(
"mime_encoding",
serde_yaml::Value::String(mime_encoding),
self.base.outputs(),
) {
metadata.push(meta_data);
}
} else {
// No charset, just mime type
if let Some(meta_data) = process_metadata_outputs(
"mime_type",
serde_yaml::Value::String(mime_line),
self.base.outputs(),
) {
metadata.push(meta_data);
}
}
}
// Get human-readable file type via --brief
if let Some(file_type) = self.run_file_command(&["--brief"])
&& !file_type.is_empty()
&& let Some(meta_data) = process_metadata_outputs(
"file_type",
serde_yaml::Value::String(file_type),
self.base.outputs(),
)
{
metadata.push(meta_data);
}
metadata
}
}
#[cfg(not(feature = "meta_magic"))]
impl MetaPlugin for FallbackMagicFileMetaPlugin {
fn is_finalized(&self) -> bool {
self.is_finalized
}
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn initialize(&mut self) -> MetaPluginResponse {
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn update(&mut self, data: &[u8]) -> MetaPluginResponse {
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let remaining = self.max_buffer_size.saturating_sub(self.buffer.len());
if remaining > 0 {
let n = std::cmp::min(data.len(), remaining);
self.buffer.extend_from_slice(&data[..n]);
if self.buffer.len() >= self.max_buffer_size {
let metadata = self.detect_type();
self.is_finalized = true;
return MetaPluginResponse {
metadata,
is_finalized: true,
};
}
}
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn finalize(&mut self) -> MetaPluginResponse {
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
self.is_finalized = true;
MetaPluginResponse {
metadata: self.detect_type(),
is_finalized: true,
}
}
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::MagicFile
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
fn default_outputs(&self) -> Vec<String> {
vec![
"mime_type".to_string(),
"mime_encoding".to_string(),
"file_type".to_string(),
]
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
fn parallel_safe(&self) -> bool {
true
}
}
#[cfg(not(feature = "meta_magic"))]
pub use FallbackMagicFileMetaPlugin as MagicFileMetaPlugin;
use crate::meta_plugin::register_meta_plugin;
#[ctor::ctor]
fn register_magic_file_plugin() {
register_meta_plugin(MetaPluginType::MagicFile, |options, outputs| {
Box::new(MagicFileMetaPlugin::new(options, outputs))
})
.expect("Failed to register MagicFileMetaPlugin");
}

655
src/meta_plugin/mod.rs Normal file
View File

@@ -0,0 +1,655 @@
use log::{debug, warn};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
pub mod cwd;
pub mod digest;
pub mod env;
pub mod exec;
pub mod hostname;
#[cfg(feature = "meta_infer")]
pub mod infer_plugin;
pub mod keep_pid;
pub mod magic_file;
pub mod read_rate;
pub mod read_time;
pub mod shell;
pub mod shell_pid;
pub mod text;
#[cfg(feature = "meta_tokens")]
pub mod tokens;
#[cfg(feature = "meta_tree_magic_mini")]
pub mod tree_magic_mini;
pub mod user;
pub use digest::DigestMetaPlugin;
pub use exec::MetaPluginExec;
#[cfg(feature = "meta_magic")]
pub use magic_file::MagicFileMetaPlugin;
// pub use text::TextMetaPlugin; // Removed duplicate
pub use cwd::CwdMetaPlugin;
pub use env::EnvMetaPlugin;
pub use hostname::HostnameMetaPlugin;
#[cfg(feature = "meta_infer")]
pub use infer_plugin::InferMetaPlugin;
pub use keep_pid::KeepPidMetaPlugin;
pub use read_rate::ReadRateMetaPlugin;
pub use read_time::ReadTimeMetaPlugin;
pub use shell::ShellMetaPlugin;
pub use shell_pid::ShellPidMetaPlugin;
#[cfg(feature = "meta_tree_magic_mini")]
pub use tree_magic_mini::TreeMagicMiniMetaPlugin;
pub use user::UserMetaPlugin;
#[cfg(not(feature = "meta_magic"))]
pub use magic_file::FallbackMagicFileMetaPlugin as MagicFileMetaPlugin;
type PluginConstructor = fn(
Option<HashMap<String, serde_yaml::Value>>,
Option<HashMap<String, serde_yaml::Value>>,
) -> Box<dyn MetaPlugin>;
/// Represents metadata to be stored.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetaData {
/// The name of the metadata field.
pub name: String,
/// The value of the metadata field.
pub value: String,
}
/// Response from meta plugin operations.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct MetaPluginResponse {
/// The generated metadata items.
pub metadata: Vec<MetaData>,
/// Indicates if the plugin has finished processing.
pub is_finalized: bool,
}
/// Type alias for the save_meta callback shared by all plugins.
pub type SaveMetaFn = Arc<Mutex<dyn FnMut(&str, &str) + Send>>;
/// Creates a no-op save_meta for plugins not wired through MetaService.
pub fn noop_save_meta() -> SaveMetaFn {
Arc::new(Mutex::new(|_: &str, _: &str| {}))
}
/// Base implementation for meta plugins to reduce boilerplate.
#[derive(Clone)]
pub struct BaseMetaPlugin {
/// Output mappings for metadata.
pub outputs: std::collections::HashMap<String, serde_yaml::Value>,
/// Configuration options for the plugin.
pub options: std::collections::HashMap<String, serde_yaml::Value>,
/// Whether the plugin is finalized.
pub is_finalized: bool,
/// Callback to store metadata. Called directly by plugins.
pub save_meta: SaveMetaFn,
}
impl std::fmt::Debug for BaseMetaPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BaseMetaPlugin")
.field("outputs", &self.outputs)
.field("options", &self.options)
.field("is_finalized", &self.is_finalized)
.finish_non_exhaustive()
}
}
impl Default for BaseMetaPlugin {
fn default() -> Self {
Self {
outputs: HashMap::new(),
options: HashMap::new(),
is_finalized: false,
save_meta: noop_save_meta(),
}
}
}
impl BaseMetaPlugin {
/// Creates a new `BaseMetaPlugin`.
///
/// # Returns
///
/// A new instance of `BaseMetaPlugin`.
pub fn new() -> Self {
Self::default()
}
/// Returns a reference to the outputs mapping.
pub fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
&self.outputs
}
/// Returns a mutable reference to the outputs mapping.
pub fn outputs_mut(&mut self) -> &mut std::collections::HashMap<String, serde_yaml::Value> {
&mut self.outputs
}
/// Returns a reference to the options mapping.
pub fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
&self.options
}
/// Returns a mutable reference to the options mapping.
pub fn options_mut(&mut self) -> &mut std::collections::HashMap<String, serde_yaml::Value> {
&mut self.options
}
/// Sets the save_meta callback on the base plugin.
pub fn set_save_meta(&mut self, save_meta: SaveMetaFn) {
self.save_meta = save_meta;
}
/// Saves a metadata entry via the save_meta callback.
pub fn save_meta(&self, name: &str, value: &str) {
if let Ok(mut f) = self.save_meta.lock() {
f(name, value);
} else {
warn!("META_PLUGIN: save_meta lock poisoned, dropping metadata: {name}={value}");
}
}
/// Helper function to initialize plugin options and outputs.
///
/// # Arguments
///
/// * `default_outputs` - Slice of default output names.
/// * `options` - Optional user-provided options.
/// * `outputs` - Optional user-provided outputs.
pub fn initialize_plugin(
&mut self,
default_outputs: &[&str],
options: &Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: &Option<std::collections::HashMap<String, serde_yaml::Value>>,
) {
// Set default outputs
for output_name in default_outputs {
self.outputs.insert(
output_name.to_string(),
serde_yaml::Value::String(output_name.to_string()),
);
}
// Apply provided options and outputs
if let Some(opts) = options {
for (key, value) in opts {
self.options.insert(key.clone(), value.clone());
}
}
if let Some(outs) = outputs {
for (key, value) in outs {
self.outputs.insert(key.clone(), value.clone());
}
}
}
}
impl MetaPlugin for BaseMetaPlugin {
/// Returns the type of this meta plugin.
///
/// # Returns
///
/// `MetaPluginType::Text` (default for base).
fn meta_type(&self) -> MetaPluginType {
// This is a base implementation, so we need to return something
// This might not be used, but we need to satisfy the trait
MetaPluginType::Text
}
/// Returns a reference to the outputs mapping.
///
/// # Returns
///
/// A reference to the `HashMap` of outputs.
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
&self.outputs
}
/// Returns a mutable reference to the outputs mapping.
///
/// # Returns
///
/// A mutable reference to the `HashMap` of outputs.
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(&mut self.outputs)
}
/// Returns a reference to the options mapping.
///
/// # Returns
///
/// A reference to the `HashMap` of options.
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
&self.options
}
/// Returns a mutable reference to the options mapping.
///
/// # Returns
///
/// A mutable reference to the `HashMap` of options.
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(&mut self.options)
}
}
#[derive(
Debug,
Eq,
PartialEq,
Clone,
Hash,
strum::EnumIter,
strum::Display,
strum::EnumString,
Serialize,
Deserialize,
)]
#[strum(serialize_all = "snake_case", ascii_case_insensitive)]
pub enum MetaPluginType {
MagicFile,
Cwd,
Text,
User,
Shell,
ShellPid,
KeepPid,
Digest,
ReadTime,
ReadRate,
Hostname,
Exec,
Env,
Tokens,
TreeMagicMini,
Infer,
}
/// Central function to handle metadata output with name mapping.
///
/// # Arguments
///
/// * `internal_name` - The internal name of the metadata.
/// * `value` - The value to process.
/// * `outputs` - The outputs mapping.
///
/// # Returns
///
/// An optional `MetaData` if the output is enabled, `None` if disabled.
pub fn process_metadata_outputs(
internal_name: &str,
value: serde_yaml::Value,
outputs: &std::collections::HashMap<String, serde_yaml::Value>,
) -> Option<MetaData> {
// Check if this output is disabled
if let Some(mapping) = outputs.get(internal_name) {
// Check for null to disable the output
if mapping.is_null() {
debug!("META: Skipping disabled output (null): {internal_name}");
return None;
}
// Check for boolean false to disable the output
if let Some(false_val) = mapping.as_bool()
&& !false_val
{
debug!("META: Skipping disabled output: {internal_name}");
return None;
}
if let Some(custom_name) = mapping.as_str() {
let value_str = yaml_value_to_string(&value);
debug!(
"META: Processing metadata: internal_name={internal_name}, custom_name={custom_name}, value={value_str}"
);
return Some(MetaData {
name: custom_name.to_string(),
value: value_str,
});
}
}
let value_str = yaml_value_to_string(&value);
// Default: use internal name as output name
debug!("META: Processing metadata: name={internal_name}, value={value_str}");
Some(MetaData {
name: internal_name.to_string(),
value: value_str,
})
}
fn yaml_value_to_string(value: &serde_yaml::Value) -> String {
match value {
serde_yaml::Value::Null => "null".to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Sequence(_)
| serde_yaml::Value::Mapping(_)
| serde_yaml::Value::Tagged(_) => {
serde_yaml::to_string(value).unwrap_or_else(|_| "".to_string())
}
}
}
pub trait MetaPlugin: Send
where
Self: 'static,
{
/// Returns the type of this meta plugin.
///
/// # Returns
///
/// The `MetaPluginType` enum variant for this plugin.
fn meta_type(&self) -> MetaPluginType;
/// Checks if the plugin is supported on the current system.
///
/// # Returns
///
/// `true` if supported, `false` otherwise.
fn is_supported(&self) -> bool {
true
}
/// Checks if the plugin is internal (built-in).
///
/// # Returns
///
/// `true` if internal, `false` otherwise.
fn is_internal(&self) -> bool {
true
}
/// Checks if the plugin is already finalized.
///
/// # Returns
///
/// `true` if finalized, `false` otherwise.
fn is_finalized(&self) -> bool {
false
}
/// Sets the finalized state (only for plugins that can track this).
///
/// # Arguments
///
/// * `_finalized` - The new finalized state (unused in default).
fn set_finalized(&mut self, _finalized: bool) {}
/// Updates the meta plugin with new data.
///
/// # Arguments
///
/// * `_data` - The data chunk to process (unused in default).
///
/// # Returns
///
/// A `MetaPluginResponse` with empty metadata and `is_finalized` set to `false`.
fn update(&mut self, _data: &[u8]) -> MetaPluginResponse {
// Default implementation does nothing
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
/// Finalizes the plugin.
///
/// # Returns
///
/// A `MetaPluginResponse` with empty metadata and `is_finalized` set to `true`.
fn finalize(&mut self) -> MetaPluginResponse {
// Default implementation does nothing
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
}
}
/// Gets program information for display in status.
///
/// # Returns
///
/// An optional tuple of program name and arguments, or `None`.
fn program_info(&self) -> Option<(&str, Vec<&str>)> {
None
}
/// Initializes the plugin.
///
/// # Returns
///
/// A `MetaPluginResponse` with empty metadata and `is_finalized` set to `false`.
fn initialize(&mut self) -> MetaPluginResponse {
// Default implementation does nothing
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
/// Returns a reference to the outputs mapping.
///
/// # Returns
///
/// An empty `HashMap` (default implementation).
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
use std::sync::LazyLock;
static EMPTY: LazyLock<std::collections::HashMap<String, serde_yaml::Value>> =
LazyLock::new(std::collections::HashMap::new);
&EMPTY
}
/// Returns a mutable reference to the outputs mapping.
///
/// # Returns
///
/// A mutable reference to the outputs `HashMap`.
///
/// # Errors
///
/// Returns an error if the plugin does not support mutable outputs.
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
anyhow::bail!("outputs_mut() not supported by this plugin")
}
/// Returns a reference to the options mapping.
///
/// # Returns
///
/// An empty `HashMap` (default implementation).
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
use std::sync::LazyLock;
static EMPTY: LazyLock<std::collections::HashMap<String, serde_yaml::Value>> =
LazyLock::new(std::collections::HashMap::new);
&EMPTY
}
/// Returns a mutable reference to the options mapping.
///
/// # Returns
///
/// A mutable reference to the options `HashMap`.
///
/// # Errors
///
/// Returns an error if the plugin does not support mutable options.
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
anyhow::bail!("options_mut() not supported by this plugin")
}
/// Gets the default output names this plugin can produce.
///
/// # Returns
///
/// A vector containing the meta type as a string (default).
fn default_outputs(&self) -> Vec<String> {
// Default implementation returns the meta type as a string
vec![self.meta_type().to_string()]
}
/// Returns a description of this plugin for display in config templates.
///
/// # Returns
///
/// A description string (empty by default).
fn description(&self) -> &str {
""
}
/// Returns true if this plugin can execute concurrently with other
/// parallel-safe plugins.
///
/// Plugins that do significant per-chunk work (hashing, tokenization,
/// piping to child processes) should return true. The MetaService will
/// run all parallel-safe plugins in separate threads per phase, then
/// process results sequentially.
fn parallel_safe(&self) -> bool {
false
}
/// Builds the schema for this plugin from its options and outputs.
///
/// Default implementation infers option types from YAML values and
/// collects enabled outputs.
///
/// # Returns
///
/// A `PluginSchema` describing this plugin's configuration.
fn schema(&self) -> crate::common::schema::PluginSchema {
use crate::common::schema::{OptionSchema, OptionType, OutputSchema, PluginSchema};
let options: Vec<OptionSchema> = self
.options()
.iter()
.map(|(key, value)| {
let option_type = OptionType::from_yaml_value(value);
let (default, required) = if value.is_null() {
(None, true)
} else {
(Some(value.clone()), false)
};
OptionSchema {
name: key.clone(),
option_type,
default,
required,
}
})
.collect();
let mut outputs: Vec<OutputSchema> = Vec::new();
for (key, value) in self.outputs() {
if !value.is_null() {
outputs.push(OutputSchema {
name: key.clone(),
description: key.clone(),
});
}
}
if outputs.is_empty() {
for output_name in self.default_outputs() {
outputs.push(OutputSchema {
name: output_name.clone(),
description: output_name,
});
}
}
PluginSchema {
name: self.meta_type().to_string(),
description: self.description().to_string(),
options,
outputs,
}
}
/// Method to downcast to concrete type (for checking finalization state).
///
/// # Returns
///
/// A mutable reference to `self` as `dyn Any`.
fn as_any_mut(&mut self) -> &mut dyn std::any::Any
where
Self: Sized,
{
self
}
/// Sets the save_meta callback for this plugin.
///
/// Called by MetaService to wire the plugin to the metadata storage.
fn set_save_meta(&mut self, _save_meta: SaveMetaFn) {}
/// Saves a metadata entry via the save_meta callback.
///
/// Plugins call this during initialize/update/finalize to persist metadata.
fn save_meta(&self, _name: &str, _value: &str) {}
}
/// Global registry for meta plugins.
static META_PLUGIN_REGISTRY: std::sync::LazyLock<
Mutex<HashMap<MetaPluginType, PluginConstructor>>,
> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
/// Register a meta plugin with the global registry.
///
/// # Arguments
///
/// * `meta_plugin_type` - The type of the meta plugin to register.
/// * `constructor` - The constructor function for creating plugin instances.
pub fn register_meta_plugin(
meta_plugin_type: MetaPluginType,
constructor: PluginConstructor,
) -> anyhow::Result<()> {
META_PLUGIN_REGISTRY
.lock()
.map_err(|e| anyhow::anyhow!("plugin registry poisoned: {e}"))?
.insert(meta_plugin_type, constructor);
Ok(())
}
pub fn get_meta_plugin(
meta_plugin_type: MetaPluginType,
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> anyhow::Result<Box<dyn MetaPlugin>> {
get_meta_plugin_with_save(meta_plugin_type, options, outputs, None)
}
/// Creates a meta plugin instance with an optional save_meta callback.
///
/// If `save_meta` is provided, it is wired to the plugin so it can
/// store metadata directly during initialize/update/finalize.
pub fn get_meta_plugin_with_save(
meta_plugin_type: MetaPluginType,
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
save_meta: Option<SaveMetaFn>,
) -> anyhow::Result<Box<dyn MetaPlugin>> {
let registry = META_PLUGIN_REGISTRY
.lock()
.map_err(|e| anyhow::anyhow!("plugin registry poisoned: {e}"))?;
if let Some(constructor) = registry.get(&meta_plugin_type) {
let mut plugin = constructor(options, outputs);
if let Some(sm) = save_meta {
plugin.set_save_meta(sm);
}
return Ok(plugin);
}
anyhow::bail!("Meta plugin {meta_plugin_type:?} not registered")
}

View File

@@ -1,151 +0,0 @@
use crate::plugins::ProgramWriter;
use anyhow::{Context, Result, anyhow};
use log::*;
use std::env;
use std::fs;
use std::io;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::process::{Command, Stdio};
use crate::meta_plugin::MetaPlugin;
#[derive(Clone, Debug)]
pub struct MetaPluginProgram {
pub program: String,
pub args: Vec<String>,
pub supported: bool,
pub meta_name: String,
pub split_whitespace: bool,
buffer: Vec<u8>,
}
impl MetaPluginProgram {
pub fn new(program: &str, args: Vec<&str>, meta_name: String, split_whitespace: bool) -> MetaPluginProgram {
let program_path = get_program_path(program);
let supported = program_path.is_ok();
MetaPluginProgram {
program: program_path.unwrap_or(program.to_string()),
args: args.iter().map(|s| s.to_string()).collect(),
supported,
meta_name,
split_whitespace,
buffer: Vec::new(),
}
}
}
impl MetaPlugin for MetaPluginProgram {
fn is_supported(&self) -> bool {
self.supported
}
fn is_internal(&self) -> bool {
false
}
fn create(&self) -> Result<Box<dyn Write>> {
debug!("META: Writing using {:?}", *self);
let program = self.program.clone();
let args = self.args.clone();
debug!("META: Executing command: {:?} {:?}", program, args);
let mut process = Command::new(program.clone())
.args(args.clone())
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.context(anyhow!(
"Problem spawning child process: {:?} {:?}",
program,
args
))?;
Ok(Box::new(ProgramWriter {
stdin: process.stdin.take().unwrap(),
}))
}
fn finalize(&mut self) -> io::Result<String> {
let program = self.program.clone();
let args = self.args.clone();
debug!("META: Executing command for finalize: {:?} {:?}", program, args);
let mut process = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("Failed to spawn process: {}", e)))?;
let stdin = process.stdin.as_mut().unwrap();
stdin.write_all(&self.buffer)
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("Failed to write to stdin: {}", e)))?;
let output = process.wait_with_output()
.map_err(|e| io::Error::new(io::ErrorKind::Other, format!("Failed to wait for process: {}", e)))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed_result = stdout.trim();
// For certain programs, we only want the first part before whitespace
if self.split_whitespace {
let parts: Vec<&str> = trimmed_result.split_whitespace().collect();
if !parts.is_empty() {
Ok(parts[0].to_string())
} else {
Ok(trimmed_result.to_string())
}
} else {
Ok(trimmed_result.to_string())
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(io::Error::new(
io::ErrorKind::Other,
format!("Command failed: {}", stderr.trim()),
))
}
}
fn update(&mut self, data: &[u8]) {
self.buffer.extend_from_slice(data);
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
fn program_info(&self) -> Option<(&str, Vec<&str>)> {
if self.supported {
Some((&self.program, self.args.iter().map(|s| s.as_str()).collect()))
} else {
None
}
}
}
fn get_program_path(program: &str) -> Result<String> {
debug!("META: Looking for executable: {}", program);
if let Ok(path) = env::var("PATH") {
for p in path.split(':') {
let p_str = format!("{}/{}", p, program);
let stat = fs::metadata(p_str.clone());
if let Ok(stat) = stat {
let md = stat;
let permissions = md.permissions();
if md.is_file() && permissions.mode() & 0o111 != 0 {
return Ok(p_str);
}
}
}
}
Err(anyhow!("Unable to find binary {} in PATH", program))
}

View File

@@ -0,0 +1,250 @@
use std::time::Instant;
use crate::meta_plugin::{BaseMetaPlugin, MetaPlugin, MetaPluginType};
#[derive(Debug, Clone, Default)]
/// Meta plugin that calculates the read rate (KB/s) of input data.
///
/// Tracks bytes read and elapsed time, then computes the rate in finalize().
/// Outputs the rate via configured mappings. Supports options for customization
/// (though defaults are used here).
///
/// # Fields
///
/// * `start_time` - Start time of reading, if begun.
/// * `bytes_read` - Total bytes accumulated.
/// * `is_finalized` - Whether processing is complete.
/// * `base` - Base plugin for outputs and options.
pub struct ReadRateMetaPlugin {
start_time: Option<Instant>,
bytes_read: u64,
is_finalized: bool,
base: BaseMetaPlugin,
}
impl ReadRateMetaPlugin {
/// Creates a new `ReadRateMetaPlugin` instance.
///
/// Initializes with default options and outputs, merging provided ones.
/// Starts tracking from zero bytes and no start time.
///
/// # Arguments
///
/// * `_options` - Optional configuration options (merged with defaults; unused specifics here).
/// * `outputs` - Optional output mappings (merged with default "read_rate").
///
/// # Returns
///
/// A new, un-finalized `ReadRateMetaPlugin` instance.
///
/// # Examples
///
/// ```
/// # use keep::meta_plugin::{ReadRateMetaPlugin, MetaPlugin};
/// let plugin = ReadRateMetaPlugin::new(None, None);
/// assert!(!plugin.is_finalized());
/// ```
pub fn new(
_options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> ReadRateMetaPlugin {
let mut base = BaseMetaPlugin::new();
// Set default outputs
let default_outputs = &["read_rate"];
base.initialize_plugin(default_outputs, &_options, &outputs);
ReadRateMetaPlugin {
start_time: None,
bytes_read: 0,
is_finalized: false,
base,
}
}
}
impl MetaPlugin for ReadRateMetaPlugin {
/// Checks if the plugin has been finalized.
///
/// # Returns
///
/// `true` if finalized (processing complete), `false` otherwise.
fn is_finalized(&self) -> bool {
self.is_finalized
}
/// Sets the finalized state of the plugin.
///
/// Marks the plugin as complete or resets it.
///
/// # Arguments
///
/// * `finalized` - Whether processing is now complete.
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
/// Finalizes the plugin, calculating the read rate.
///
/// Computes KB/s from bytes read and elapsed time. Outputs via mappings.
/// Idempotent: skips if already finalized.
///
/// # Returns
///
/// A `MetaPluginResponse` with rate metadata (if computable) and finalized=true.
///
/// # Errors
///
/// None; returns empty metadata if no start time or zero duration.
fn finalize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
if let Some(start_time) = self.start_time {
let duration = start_time.elapsed();
let rate = if duration.as_secs_f64() > 0.0 {
format!(
"{:.2} KB/s",
(self.bytes_read as f64 / 1024.0) / duration.as_secs_f64()
)
} else {
"N/A".to_string()
};
// Use process_metadata_outputs to handle output mapping
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
"read_rate",
serde_yaml::Value::String(rate),
self.base.outputs(),
) {
metadata.push(meta_data);
}
}
// Mark as finalized
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata,
is_finalized: true,
}
}
/// Updates the plugin with new data, accumulating bytes read.
///
/// Starts timer on first update if not set. Accumulates byte count.
/// Idempotent post-finalize: ignores data.
///
/// # Arguments
///
/// * `data` - Byte slice to process (length added to total).
///
/// # Returns
///
/// `MetaPluginResponse` with no metadata and finalized=false (unless already done).
fn update(&mut self, data: &[u8]) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process more data
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
if self.start_time.is_none() {
self.start_time = Some(Instant::now());
}
self.bytes_read += data.len() as u64;
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
/// Returns the type of this meta plugin.
///
/// # Returns
///
/// `MetaPluginType::ReadRate`.
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::ReadRate
}
/// Returns a reference to the outputs mapping.
///
/// # Returns
///
/// Immutable reference to the outputs HashMap.
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
/// Returns a mutable reference to the outputs mapping.
///
/// Allows modification of output configurations.
///
/// # Returns
///
/// Mutable reference to the outputs HashMap.
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
/// Returns the default output names for this plugin.
///
/// # Returns
///
/// Vector containing "read_rate".
fn default_outputs(&self) -> Vec<String> {
vec!["read_rate".to_string()]
}
/// Returns a reference to the options mapping.
///
/// # Returns
///
/// Immutable reference to the options HashMap.
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
/// Returns a mutable reference to the options mapping.
///
/// Allows modification of plugin options.
///
/// # Returns
///
/// Mutable reference to the options HashMap.
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
}
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_read_rate_plugin() {
register_meta_plugin(MetaPluginType::ReadRate, |options, outputs| {
Box::new(ReadRateMetaPlugin::new(options, outputs))
})
.expect("Failed to register ReadRateMetaPlugin");
}

View File

@@ -0,0 +1,137 @@
use std::time::Instant;
use crate::meta_plugin::{BaseMetaPlugin, MetaPlugin, MetaPluginType};
#[derive(Debug, Clone, Default)]
pub struct ReadTimeMetaPlugin {
start_time: Option<Instant>,
is_finalized: bool,
base: BaseMetaPlugin,
}
impl ReadTimeMetaPlugin {
pub fn new(
_options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> ReadTimeMetaPlugin {
let mut base = BaseMetaPlugin::new();
// Set default outputs
let default_outputs = &["read_time"];
base.initialize_plugin(default_outputs, &_options, &outputs);
ReadTimeMetaPlugin {
start_time: None,
is_finalized: false,
base,
}
}
}
impl MetaPlugin for ReadTimeMetaPlugin {
fn is_finalized(&self) -> bool {
self.is_finalized
}
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn finalize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
if let Some(start_time) = self.start_time {
let duration = start_time.elapsed();
let duration_str = format!("{:.3} seconds", duration.as_secs_f64());
// Use process_metadata_outputs to handle output mapping
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
"read_time",
serde_yaml::Value::String(duration_str),
self.base.outputs(),
) {
metadata.push(meta_data);
}
}
// Mark as finalized
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata,
is_finalized: true,
}
}
fn update(&mut self, _data: &[u8]) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process more data
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
if self.start_time.is_none() {
self.start_time = Some(Instant::now());
}
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::ReadTime
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
fn default_outputs(&self) -> Vec<String> {
vec!["read_time".to_string()]
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
}
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_read_time_plugin() {
register_meta_plugin(MetaPluginType::ReadTime, |options, outputs| {
Box::new(ReadTimeMetaPlugin::new(options, outputs))
})
.expect("Failed to register ReadTimeMetaPlugin");
}

253
src/meta_plugin/shell.rs Normal file
View File

@@ -0,0 +1,253 @@
use std::env;
use crate::meta_plugin::{BaseMetaPlugin, MetaPlugin, MetaPluginType};
#[derive(Debug, Clone, Default)]
/// Meta plugin for capturing shell environment information.
///
/// This plugin retrieves the current shell from the SHELL environment variable
/// and provides it as metadata. It runs once during initialization and does not
/// process input data.
pub struct ShellMetaPlugin {
is_finalized: bool,
base: BaseMetaPlugin,
}
impl ShellMetaPlugin {
/// Creates a new ShellMetaPlugin instance.
///
/// Initializes with default outputs and options, overridden by provided values.
/// Defaults to "shell" as the output key.
///
/// # Arguments
///
/// * `_options` - Optional configuration options (unused currently).
/// * `outputs` - Optional output mappings to override defaults.
///
/// # Returns
///
/// * `ShellMetaPlugin` - A new instance with processed options and outputs.
///
/// # Examples
///
/// ```
/// # use keep::meta_plugin::ShellMetaPlugin;
/// let plugin = ShellMetaPlugin::new(None, None);
/// ```
pub fn new(
_options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> ShellMetaPlugin {
let mut base = BaseMetaPlugin::new();
// Set default outputs
let default_outputs = &["shell"];
base.initialize_plugin(default_outputs, &_options, &outputs);
ShellMetaPlugin {
is_finalized: false,
base,
}
}
}
impl MetaPlugin for ShellMetaPlugin {
/// Checks if the plugin has been finalized.
///
/// # Returns
///
/// * `bool` - True if finalized, false otherwise.
fn is_finalized(&self) -> bool {
self.is_finalized
}
/// Sets the finalized state of the plugin.
///
/// # Arguments
///
/// * `finalized` - The new finalized state.
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
/// Finalizes the plugin without processing data.
///
/// For this plugin, finalization is handled in `initialize`, so this returns empty metadata.
///
/// # Returns
///
/// * `MetaPluginResponse` - Response with no metadata and finalized state.
fn finalize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
// Mark as finalized
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
}
}
/// Updates the plugin with data (not used for shell).
///
/// Shell plugin doesn't process data streams; returns empty response unless not finalized.
///
/// # Arguments
///
/// * `_data` - Byte slice of input data (ignored).
///
/// # Returns
///
/// * `MetaPluginResponse` - Empty metadata response.
fn update(&mut self, _data: &[u8]) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process more data
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
/// Returns the type of this meta plugin.
///
/// # Returns
///
/// * `MetaPluginType::Shell` - The shell plugin type.
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::Shell
}
/// Initializes the plugin and extracts shell metadata.
///
/// Retrieves the SHELL environment variable and adds it to metadata.
/// Marks the plugin as finalized after one run.
///
/// # Returns
///
/// * `MetaPluginResponse` - Response with shell metadata and finalized state.
///
/// # Examples
///
/// ```
/// # use keep::meta_plugin::{ShellMetaPlugin, MetaPlugin};
/// let mut plugin = ShellMetaPlugin::new(None, None);
/// let response = plugin.initialize();
/// assert!(response.is_finalized);
/// ```
fn initialize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
let shell = match env::var("SHELL") {
Ok(shell) => shell,
Err(_) => "unknown".to_string(),
};
// Use process_metadata_outputs to handle output mapping
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
"shell",
serde_yaml::Value::String(shell),
self.base.outputs(),
) {
metadata.push(meta_data);
}
// Mark as finalized since this plugin only needs to run once
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata,
is_finalized: true,
}
}
/// Returns a reference to the plugin's outputs.
///
/// # Returns
///
/// * `&HashMap<String, serde_yaml::Value>` - The outputs map.
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
/// Returns a mutable reference to the plugin's outputs.
///
/// # Returns
///
/// * `&mut HashMap<String, serde_yaml::Value>` - Mutable outputs map.
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
/// Returns the default output names for this plugin.
///
/// # Returns
///
/// Vector containing "shell".
fn default_outputs(&self) -> Vec<String> {
vec!["shell".to_string()]
}
/// Returns a reference to the plugin's options.
///
/// # Returns
///
/// * `&HashMap<String, serde_yaml::Value>` - The options map.
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
/// Returns a mutable reference to the plugin's options.
///
/// # Returns
///
/// * `&mut HashMap<String, serde_yaml::Value>` - Mutable options map.
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
}
/// Registers the shell meta plugin with the global registry.
///
/// This constructor function is called at module load time using ctor crate.
/// It creates the plugin with provided options and outputs.
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_shell_plugin() {
register_meta_plugin(MetaPluginType::Shell, |options, outputs| {
Box::new(ShellMetaPlugin::new(options, outputs))
})
.expect("Failed to register ShellMetaPlugin");
}

View File

@@ -0,0 +1,145 @@
use crate::meta_plugin::{BaseMetaPlugin, MetaPlugin, MetaPluginType};
use std::env;
use std::process;
#[derive(Debug, Clone, Default)]
pub struct ShellPidMetaPlugin {
is_finalized: bool,
base: BaseMetaPlugin,
}
impl ShellPidMetaPlugin {
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> ShellPidMetaPlugin {
let mut base = BaseMetaPlugin::new();
// Set default outputs
let default_outputs = &["shell_pid"];
base.initialize_plugin(default_outputs, &options, &outputs);
ShellPidMetaPlugin {
is_finalized: false,
base,
}
}
}
impl MetaPlugin for ShellPidMetaPlugin {
fn is_finalized(&self) -> bool {
self.is_finalized
}
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn finalize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
// Mark as finalized
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
}
}
fn update(&mut self, _data: &[u8]) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process more data
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::ShellPid
}
fn initialize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return crate::meta_plugin::MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
let pid = match env::var("PPID") {
Ok(ppid) => ppid,
Err(_) => process::id().to_string(),
};
// Use process_metadata_outputs to handle output mapping
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
"shell_pid",
serde_yaml::Value::String(pid),
self.base.outputs(),
) {
metadata.push(meta_data);
}
// Mark as finalized since this plugin only needs to run once
self.is_finalized = true;
crate::meta_plugin::MetaPluginResponse {
metadata,
is_finalized: true,
}
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
}
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_shell_pid_plugin() {
register_meta_plugin(MetaPluginType::ShellPid, |options, outputs| {
Box::new(ShellPidMetaPlugin::new(options, outputs))
})
.expect("Failed to register ShellPidMetaPlugin");
}

View File

@@ -1,448 +0,0 @@
use anyhow::Result;
use gethostname::gethostname;
use local_ip_address::local_ip;
use dns_lookup::lookup_addr;
use std::io;
use std::io::Write;
use std::env;
use std::process;
use uzers::{get_current_uid, get_current_gid, get_current_username, get_current_groupname};
use crate::common::is_binary;
use crate::meta_plugin::MetaPlugin;
#[derive(Debug, Clone, Default)]
pub struct CwdMetaPlugin {
meta_name: String,
}
#[derive(Debug, Clone, Default)]
pub struct BinaryMetaPlugin {
meta_name: String,
buffer: Vec<u8>,
max_buffer_size: usize,
}
impl BinaryMetaPlugin {
pub fn new() -> BinaryMetaPlugin {
BinaryMetaPlugin {
meta_name: "binary".to_string(),
buffer: Vec::new(),
max_buffer_size: 4096, // 4KB
}
}
}
impl MetaPlugin for BinaryMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
let is_binary = is_binary(&self.buffer);
Ok(if is_binary { "true".to_string() } else { "false".to_string() })
}
fn update(&mut self, data: &[u8]) {
// Only collect up to max_buffer_size
let remaining_capacity = self.max_buffer_size.saturating_sub(self.buffer.len());
if remaining_capacity > 0 {
let bytes_to_copy = std::cmp::min(data.len(), remaining_capacity);
self.buffer.extend_from_slice(&data[..bytes_to_copy]);
}
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
impl CwdMetaPlugin {
pub fn new() -> CwdMetaPlugin {
CwdMetaPlugin {
meta_name: "cwd".to_string(),
}
}
}
impl MetaPlugin for CwdMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
match env::current_dir() {
Ok(path) => Ok(path.to_string_lossy().to_string()),
Err(_) => Ok("unknown".to_string()),
}
}
fn update(&mut self, _data: &[u8]) {
// No update needed
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct UidMetaPlugin {
meta_name: String,
}
impl UidMetaPlugin {
pub fn new() -> UidMetaPlugin {
UidMetaPlugin {
meta_name: "uid".to_string(),
}
}
}
impl MetaPlugin for UidMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
Ok(get_current_uid().to_string())
}
fn update(&mut self, _data: &[u8]) {
// No update needed
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct UserMetaPlugin {
meta_name: String,
}
impl UserMetaPlugin {
pub fn new() -> UserMetaPlugin {
UserMetaPlugin {
meta_name: "user".to_string(),
}
}
}
impl MetaPlugin for UserMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
match get_current_username() {
Some(username) => Ok(username.to_string_lossy().to_string()),
None => Ok("unknown".to_string()),
}
}
fn update(&mut self, _data: &[u8]) {
// No update needed
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct GidMetaPlugin {
meta_name: String,
}
impl GidMetaPlugin {
pub fn new() -> GidMetaPlugin {
GidMetaPlugin {
meta_name: "gid".to_string(),
}
}
}
impl MetaPlugin for GidMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
Ok(get_current_gid().to_string())
}
fn update(&mut self, _data: &[u8]) {
// No update needed
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct GroupMetaPlugin {
meta_name: String,
}
impl GroupMetaPlugin {
pub fn new() -> GroupMetaPlugin {
GroupMetaPlugin {
meta_name: "group".to_string(),
}
}
}
impl MetaPlugin for GroupMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
match get_current_groupname() {
Some(groupname) => Ok(groupname.to_string_lossy().to_string()),
None => Ok("unknown".to_string()),
}
}
fn update(&mut self, _data: &[u8]) {
// No update needed
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct ShellMetaPlugin {
meta_name: String,
}
impl ShellMetaPlugin {
pub fn new() -> ShellMetaPlugin {
ShellMetaPlugin {
meta_name: "shell".to_string(),
}
}
}
impl MetaPlugin for ShellMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
match env::var("SHELL") {
Ok(shell) => Ok(shell),
Err(_) => Ok("unknown".to_string()),
}
}
fn update(&mut self, _data: &[u8]) {
// No update needed
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct ShellPidMetaPlugin {
meta_name: String,
}
impl ShellPidMetaPlugin {
pub fn new() -> ShellPidMetaPlugin {
ShellPidMetaPlugin {
meta_name: "shell_pid".to_string(),
}
}
}
impl MetaPlugin for ShellPidMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
match env::var("PPID") {
Ok(ppid) => Ok(ppid),
Err(_) => Ok(process::id().to_string()),
}
}
fn update(&mut self, _data: &[u8]) {
// No update needed
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct KeepPidMetaPlugin {
meta_name: String,
}
impl KeepPidMetaPlugin {
pub fn new() -> KeepPidMetaPlugin {
KeepPidMetaPlugin {
meta_name: "keep_pid".to_string(),
}
}
}
impl MetaPlugin for KeepPidMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
Ok(process::id().to_string())
}
fn update(&mut self, _data: &[u8]) {
// No update needed
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct HostnameMetaPlugin {
meta_name: String,
}
impl HostnameMetaPlugin {
pub fn new() -> HostnameMetaPlugin {
HostnameMetaPlugin {
meta_name: "hostname".to_string(),
}
}
}
impl MetaPlugin for HostnameMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
match gethostname().into_string() {
Ok(hostname) => Ok(hostname),
Err(_) => Ok("unknown".to_string()),
}
}
fn update(&mut self, _data: &[u8]) {
// No update needed for hostname
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}
#[derive(Debug, Clone, Default)]
pub struct FullHostnameMetaPlugin {
meta_name: String,
}
impl FullHostnameMetaPlugin {
pub fn new() -> FullHostnameMetaPlugin {
FullHostnameMetaPlugin {
meta_name: "full_hostname".to_string(),
}
}
}
impl MetaPlugin for FullHostnameMetaPlugin {
fn is_internal(&self) -> bool {
true
}
fn create(&self) -> Result<Box<dyn Write>> {
Ok(Box::new(io::sink()))
}
fn finalize(&mut self) -> io::Result<String> {
// Try to get the FQDN through reverse DNS lookup
match local_ip() {
Ok(my_local_ip) => {
match lookup_addr(&my_local_ip) {
Ok(hostname) => Ok(hostname),
Err(_) => {
// Fall back to regular hostname if reverse DNS fails
match gethostname().into_string() {
Ok(hostname) => Ok(hostname),
Err(_) => Ok("unknown".to_string()),
}
}
}
}
Err(_) => {
// Fall back to regular hostname if we can't get local IP
match gethostname().into_string() {
Ok(hostname) => Ok(hostname),
Err(_) => Ok("unknown".to_string()),
}
}
}
}
fn update(&mut self, _data: &[u8]) {
// No update needed for full hostname
}
fn meta_name(&mut self) -> String {
self.meta_name.clone()
}
}

831
src/meta_plugin/text.rs Normal file
View File

@@ -0,0 +1,831 @@
use crate::common::PIPESIZE;
use crate::common::is_binary::is_binary;
use crate::meta_plugin::{MetaPlugin, MetaPluginResponse, MetaPluginType};
#[derive(Debug, Clone)]
pub struct TextMetaPlugin {
buffer: Option<Vec<u8>>,
max_buffer_size: usize,
is_finalized: bool,
word_count: usize,
line_count: usize,
is_binary_content: Option<bool>,
// State for tracking word boundaries across chunks
in_word: bool,
// Buffer for handling UTF-8 character boundaries
utf8_buffer: Vec<u8>,
base: crate::meta_plugin::BaseMetaPlugin,
// Options to track specific statistics
track_word_count: bool,
track_line_count: bool,
track_line_lengths: bool,
// Flags for which line length statistics to output
output_line_max_len: bool,
output_line_mean_len: bool,
output_line_median_len: bool,
// For tracking line lengths
line_lengths: Option<Vec<usize>>,
current_line_length: usize,
// For incremental calculation of max and mean
max_line_length: usize,
total_line_length: usize,
line_count_for_stats: usize,
}
impl TextMetaPlugin {
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> TextMetaPlugin {
let mut base = crate::meta_plugin::BaseMetaPlugin::new();
// Initialize with helper function
base.initialize_plugin(
&[
"text",
"text_word_count",
"text_line_count",
"text_line_max_len",
"text_line_mean_len",
"text_line_median_len",
],
&options,
&outputs,
);
// Set disabled outputs to null based on options
let outputs_to_disable = vec![
("text_word_count", "text_word_count"),
("text_line_count", "text_line_count"),
("text_line_max_len", "text_line_max_len"),
("text_line_mean_len", "text_line_mean_len"),
("text_line_median_len", "text_line_median_len"),
];
for (option_name, output_name) in outputs_to_disable {
if let Some(value) = base.options.get(option_name) {
// Handle both boolean false and string "false"
let should_disable = match value {
serde_yaml::Value::Bool(b) => !b,
serde_yaml::Value::String(s) => s == "false",
_ => false,
};
if should_disable {
base.outputs
.insert(output_name.to_string(), serde_yaml::Value::Null);
}
}
}
// Set default options if not provided
let default_options = vec![
(
"text_detect_size",
serde_yaml::Value::Number(PIPESIZE.into()),
),
("text_word_count", serde_yaml::Value::Bool(true)),
("text_line_count", serde_yaml::Value::Bool(true)),
("text_line_max_len", serde_yaml::Value::Bool(true)),
("text_line_mean_len", serde_yaml::Value::Bool(true)),
("text_line_median_len", serde_yaml::Value::Bool(false)),
];
for (key, value) in default_options {
if !base.options.contains_key(key) {
base.options.insert(key.to_string(), value);
}
}
// Get text_detect_size (previously max_buffer_size)
let max_buffer_size = base
.options
.get("text_detect_size")
.or_else(|| base.options.get("max_buffer_size")) // Handle backward compatibility
.and_then(|v| v.as_u64())
.unwrap_or(PIPESIZE as u64) as usize;
// Get which statistics to track
let track_word_count = base
.options
.get("text_word_count")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let track_line_count = base
.options
.get("text_line_count")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let track_line_max_len = base
.options
.get("text_line_max_len")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let track_line_mean_len = base
.options
.get("text_line_mean_len")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let track_line_median_len = base
.options
.get("text_line_median_len")
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Track line lengths if any of the line length options are enabled
let track_line_lengths = track_line_max_len || track_line_mean_len || track_line_median_len;
TextMetaPlugin {
buffer: Some(Vec::new()),
max_buffer_size,
is_finalized: false,
word_count: 0,
line_count: 0,
is_binary_content: None,
in_word: false,
utf8_buffer: Vec::new(),
base,
// Add fields for line length tracking
track_word_count,
track_line_count,
track_line_lengths,
// Set output flags
output_line_max_len: track_line_max_len,
output_line_mean_len: track_line_mean_len,
output_line_median_len: track_line_median_len,
line_lengths: if track_line_lengths {
Some(Vec::new())
} else {
None
},
current_line_length: 0,
// Initialize incremental tracking for max and mean
max_line_length: 0,
total_line_length: 0,
line_count_for_stats: 0,
}
}
/// Count words and lines in a text chunk, handling block boundaries correctly.
///
/// Processes UTF-8 data, tracks word transitions, and updates line length statistics.
///
/// # Arguments
///
/// * `data` - Byte slice of text content.
fn count_text_stats(&mut self, data: &[u8]) {
// Count lines (newlines) if needed
if self.track_line_count {
self.line_count += data.iter().filter(|&&b| b == b'\n').count();
}
// Handle UTF-8 character boundaries by combining with any buffered bytes
let combined_data = if !self.utf8_buffer.is_empty() {
let mut combined = self.utf8_buffer.clone();
combined.extend_from_slice(data);
combined
} else {
data.to_vec()
};
// Clear the UTF-8 buffer
self.utf8_buffer.clear();
// Convert to string, handling potential UTF-8 boundaries
let text = match std::str::from_utf8(&combined_data) {
Ok(text) => text,
Err(e) => {
// If we have incomplete UTF-8 at the end, buffer those bytes for next chunk
let valid_up_to = e.valid_up_to();
if valid_up_to < combined_data.len() {
self.utf8_buffer
.extend_from_slice(&combined_data[valid_up_to..]);
}
match std::str::from_utf8(&combined_data[..valid_up_to]) {
Ok(text) => text,
Err(_) => return, // Can't process this data
}
}
};
// Count words if needed
if self.track_word_count {
for ch in text.chars() {
let is_whitespace = ch.is_whitespace();
if !self.in_word && !is_whitespace {
// Transition from whitespace to word - start of new word
self.word_count += 1;
self.in_word = true;
} else if self.in_word && is_whitespace {
// Transition from word to whitespace - end of current word
self.in_word = false;
}
}
}
// Track line lengths if needed
if self.track_line_lengths {
for ch in text.chars() {
if ch == '\n' {
// Update max line length
if self.current_line_length > self.max_line_length {
self.max_line_length = self.current_line_length;
}
// Update total for mean calculation
self.total_line_length += self.current_line_length;
self.line_count_for_stats += 1;
// Only store individual lengths if median is needed
if let Some(ref mut lengths) = self.line_lengths {
lengths.push(self.current_line_length);
}
self.current_line_length = 0;
} else {
self.current_line_length += 1;
}
}
}
}
/// Helper method to perform binary detection and return appropriate metadata.
///
/// Uses the is_binary function to check the buffer and sets text-related outputs accordingly.
///
/// # Arguments
///
/// * `buffer` - Data to check for binary content.
///
/// # Returns
///
/// * `(Vec<MetaData>, bool)` - Metadata updates and whether content is binary.
fn perform_binary_detection(
&mut self,
buffer: &[u8],
) -> (Vec<crate::meta_plugin::MetaData>, bool) {
let mut metadata = Vec::new();
let is_binary_result = is_binary(buffer);
self.is_binary_content = Some(is_binary_result);
// Output text status
let text_value = if is_binary_result {
"false".to_string()
} else {
"true".to_string()
};
// Use process_metadata_outputs to handle output mapping
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
"text",
serde_yaml::Value::String(text_value),
self.base.outputs(),
) {
metadata.push(meta_data);
}
// If content is binary, set all text-related outputs to None
if is_binary_result {
let text_outputs = vec![
"text_word_count",
"text_line_count",
"text_line_max_len",
"text_line_mean_len",
"text_line_median_len",
];
for output_name in text_outputs {
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
output_name,
serde_yaml::Value::Null,
self.base.outputs(),
) {
metadata.push(meta_data);
}
}
}
(metadata, is_binary_result)
}
/// Helper method to process the remaining UTF-8 buffer and finalize text statistics.
///
/// Calls count_text_stats with empty data to handle any pending UTF-8 bytes.
fn process_remaining_utf8_buffer(&mut self) {
if !self.utf8_buffer.is_empty() {
self.count_text_stats(&[]);
}
}
/// Helper method to handle the last line when tracking line lengths.
///
/// Updates statistics for any unfinished line at EOF.
fn handle_last_line_for_length_tracking(&mut self) {
if self.track_line_lengths && self.current_line_length > 0 {
// Update max line length for the last line
if self.current_line_length > self.max_line_length {
self.max_line_length = self.current_line_length;
}
// Update total for mean calculation for the last line
self.total_line_length += self.current_line_length;
self.line_count_for_stats += 1;
// Only store individual lengths if median is needed
if let Some(ref mut lengths) = self.line_lengths {
lengths.push(self.current_line_length);
}
}
}
/// Helper method to output word count metadata.
///
/// # Returns
///
/// * `Option<MetaData>` - Metadata entry if tracking is enabled.
fn output_word_count_metadata(&self) -> Option<crate::meta_plugin::MetaData> {
if self.track_word_count {
crate::meta_plugin::process_metadata_outputs(
"text_word_count",
serde_yaml::Value::String(self.word_count.to_string()),
self.base.outputs(),
)
} else {
None
}
}
/// Helper method to output line count metadata.
///
/// # Returns
///
/// * `Option<MetaData>` - Metadata entry if tracking is enabled.
fn output_line_count_metadata(&self) -> Option<crate::meta_plugin::MetaData> {
if self.track_line_count {
crate::meta_plugin::process_metadata_outputs(
"text_line_count",
serde_yaml::Value::String(self.line_count.to_string()),
self.base.outputs(),
)
} else {
None
}
}
/// Helper method to output max line length metadata.
///
/// # Returns
///
/// * `Option<MetaData>` - Metadata entry if enabled and data exists.
fn output_max_line_length_metadata(&self) -> Option<crate::meta_plugin::MetaData> {
if self.output_line_max_len && self.line_count_for_stats > 0 {
crate::meta_plugin::process_metadata_outputs(
"text_line_max_len",
serde_yaml::Value::String(self.max_line_length.to_string()),
self.base.outputs(),
)
} else {
None
}
}
/// Helper method to output mean line length metadata.
///
/// Computes average line length and rounds to nearest integer.
///
/// # Returns
///
/// * `Option<MetaData>` - Metadata entry if enabled and data exists.
fn output_mean_line_length_metadata(&self) -> Option<crate::meta_plugin::MetaData> {
if self.output_line_mean_len && self.line_count_for_stats > 0 {
let mean_len = self.total_line_length as f64 / self.line_count_for_stats as f64;
// Round to nearest integer
let mean_len_int = mean_len.round() as usize;
crate::meta_plugin::process_metadata_outputs(
"text_line_mean_len",
serde_yaml::Value::String(mean_len_int.to_string()),
self.base.outputs(),
)
} else {
None
}
}
/// Helper method to output median line length metadata.
///
/// Sorts line lengths and computes median (average of middle two for even count).
///
/// # Returns
///
/// * `Option<MetaData>` - Metadata entry if enabled and data exists.
fn output_median_line_length_metadata(&self) -> Option<crate::meta_plugin::MetaData> {
if self.output_line_median_len
&& let Some(lengths) = &self.line_lengths
&& !lengths.is_empty()
{
let mut sorted_lengths = lengths.clone();
sorted_lengths.sort();
let median_len = if lengths.len() % 2 == 0 {
(sorted_lengths[lengths.len() / 2 - 1] + sorted_lengths[lengths.len() / 2]) as f64
/ 2.0
} else {
sorted_lengths[lengths.len() / 2] as f64
};
return crate::meta_plugin::process_metadata_outputs(
"text_line_median_len",
serde_yaml::Value::String(median_len.to_string()),
self.base.outputs(),
);
}
None
}
/// Helper method to output word and line counts.
///
/// Finalizes pending data and collects all enabled text statistics metadata.
///
/// # Returns
///
/// * `Vec<MetaData>` - List of metadata entries.
fn output_word_line_counts(&mut self) -> Vec<crate::meta_plugin::MetaData> {
// Process any remaining data in utf8_buffer
self.process_remaining_utf8_buffer();
// Handle the last line if tracking line lengths
self.handle_last_line_for_length_tracking();
// Collect all metadata outputs
let mut metadata = Vec::new();
// Add metadata outputs using a more concise approach
let outputs_to_check = vec![
(self.output_word_count_metadata(), "word count"),
(self.output_line_count_metadata(), "line count"),
];
for (output, _) in outputs_to_check {
if let Some(meta_data) = output {
metadata.push(meta_data);
}
}
// Output line length statistics if tracked
if self.track_line_lengths && self.line_count_for_stats > 0 {
let line_stats_outputs = vec![
(self.output_max_line_length_metadata(), "max line length"),
(self.output_mean_line_length_metadata(), "mean line length"),
(
self.output_median_line_length_metadata(),
"median line length",
),
];
for (output, _) in line_stats_outputs {
if let Some(meta_data) = output {
metadata.push(meta_data);
}
}
}
metadata
}
}
impl MetaPlugin for TextMetaPlugin {
/// Checks if the plugin has been finalized.
///
/// # Returns
///
/// `true` if finalized, `false` otherwise.
fn is_finalized(&self) -> bool {
self.is_finalized
}
/// Sets the finalized state of the plugin.
///
/// # Arguments
///
/// * `finalized` - The new finalized state.
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
/// Updates the plugin with new data chunk.
///
/// Accumulates data for binary detection (if pending) or text statistics.
/// Finalizes early if binary content is detected.
///
/// # Arguments
///
/// * `data` - Byte slice of content chunk.
///
/// # Returns
///
/// * `MetaPluginResponse` - Current metadata and finalized status.
fn update(&mut self, data: &[u8]) -> MetaPluginResponse {
// If already finalized, don't process more data
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
// If we haven't determined if content is binary yet, build buffer and check
if self.is_binary_content.is_none() {
let should_finalize = if let Some(ref mut buffer) = self.buffer {
// Add data to our buffer up to max_buffer_size
let remaining_capacity = self.max_buffer_size.saturating_sub(buffer.len());
let bytes_to_take = std::cmp::min(data.len(), remaining_capacity);
buffer.extend_from_slice(&data[..bytes_to_take]);
// If we have enough data to make a binary determination, do it now
let buffer_len = buffer.len();
if buffer_len >= std::cmp::min(1024, self.max_buffer_size) {
// Clone the buffer data for binary detection to avoid borrowing conflicts
let buffer_clone = buffer.clone();
let (binary_metadata, is_binary) = self.perform_binary_detection(&buffer_clone);
metadata.extend(binary_metadata);
self.is_binary_content = Some(is_binary);
// If it's binary, we're done with this plugin
if is_binary {
self.buffer = None; // Drop the buffer
self.is_finalized = true;
return MetaPluginResponse {
metadata,
is_finalized: true,
};
}
// If it's text, count words and lines for this chunk
self.count_text_stats(&data[..bytes_to_take]);
// If we've reached our buffer limit, drop the buffer to save memory
// But don't finalize yet - we need to keep counting words and lines
if buffer_len >= self.max_buffer_size {
self.buffer = None; // Drop the buffer
}
false // Never finalize here for text content
} else {
// Still building up buffer, count words and lines for this chunk
self.count_text_stats(&data[..bytes_to_take]);
false
}
} else {
false
};
if should_finalize {
return MetaPluginResponse {
metadata,
is_finalized: true,
};
}
} else if self.is_binary_content == Some(false) {
// We've already determined it's text, just count words and lines
self.count_text_stats(data);
}
// If is_binary_content == Some(true), we should have already finalized, but just in case:
else if self.is_binary_content == Some(true) {
self.is_finalized = true;
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
MetaPluginResponse {
metadata,
is_finalized: self.is_finalized,
}
}
/// Finalizes the plugin and emits all pending text statistics.
///
/// Performs binary detection if not done, then outputs enabled statistics.
/// Handles head/tail options for content preview (future implementation).
///
/// # Returns
///
/// * `MetaPluginResponse` - Final metadata and finalized status.
fn finalize(&mut self) -> MetaPluginResponse {
// If already finalized, don't process again
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
// Check if we have head/tail options
let head_bytes = self
.base
.options
.get("head_bytes")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let head_lines = self
.base
.options
.get("head_lines")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let tail_bytes = self
.base
.options
.get("tail_bytes")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
let tail_lines = self
.base
.options
.get("tail_lines")
.and_then(|v| v.as_u64())
.map(|v| v as usize);
// If we haven't determined binary status yet, do it now with whatever we have
if self.is_binary_content.is_none()
&& let Some(buffer) = &self.buffer
&& !buffer.is_empty()
{
let buffer = if head_bytes.is_some()
|| head_lines.is_some()
|| tail_bytes.is_some()
|| tail_lines.is_some()
{
// Build filter string from individual parameters
let mut filter_parts = Vec::new();
if let Some(bytes) = head_bytes {
filter_parts.push(format!("head_bytes({bytes})"));
}
if let Some(lines) = head_lines {
filter_parts.push(format!("head_lines({lines})"));
}
if let Some(bytes) = tail_bytes {
filter_parts.push(format!("tail_bytes({bytes})"));
}
if let Some(lines) = tail_lines {
filter_parts.push(format!("tail_lines({lines})"));
}
// Apply filters if any are specified
let filter_string = filter_parts.join(",");
match crate::services::FilterService::new()
.process_with_filter(buffer, Some(&filter_string))
{
Ok(filtered) => filtered,
Err(e) => {
log::warn!("Failed to apply filters: {e}");
buffer.clone()
}
}
} else {
buffer.clone()
};
// Clone the processed buffer data for binary detection
let (binary_metadata, is_binary) = self.perform_binary_detection(&buffer);
metadata.extend(binary_metadata);
self.is_binary_content = Some(is_binary);
// If it's binary, we're done
if is_binary {
self.buffer = None; // Drop the buffer
self.is_finalized = true;
// Set all text-related outputs to None since content is binary
// Only include outputs that are enabled in the configuration
let text_outputs = vec![
("text_word_count", self.track_word_count),
("text_line_count", self.track_line_count),
("text_line_max_len", self.output_line_max_len),
("text_line_mean_len", self.output_line_mean_len),
("text_line_median_len", self.output_line_median_len),
];
for (output_name, is_enabled) in text_outputs {
if is_enabled
&& let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
output_name,
serde_yaml::Value::Null,
self.base.outputs(),
)
{
metadata.push(meta_data);
}
}
return MetaPluginResponse {
metadata,
is_finalized: true,
};
}
}
// If content is text, output word and line counts
if self.is_binary_content == Some(false) {
let word_line_metadata = self.output_word_line_counts();
metadata.extend(word_line_metadata);
}
// Only include outputs that are enabled in the configuration
// Disabled outputs should not be emitted at all (not even as null)
// So we don't need to add anything for disabled outputs
// Drop the buffer since we're done with it
self.buffer = None;
// Mark as finalized
self.is_finalized = true;
MetaPluginResponse {
metadata,
is_finalized: true,
}
}
/// Returns the type of this meta plugin.
///
/// # Returns
///
/// `MetaPluginType::Text`.
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::Text
}
/// Returns a reference to the outputs mapping.
///
/// # Returns
///
/// A reference to the `HashMap` of outputs.
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
/// Returns a mutable reference to the outputs mapping.
///
/// # Returns
///
/// A mutable reference to the `HashMap` of outputs.
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
/// Returns the default output names for this plugin.
///
/// # Returns
///
/// Vector of default output field names.
fn default_outputs(&self) -> Vec<String> {
vec![
"text".to_string(),
"text_word_count".to_string(),
"text_line_count".to_string(),
"text_line_max_len".to_string(),
"text_line_mean_len".to_string(),
"text_line_median_len".to_string(),
]
}
/// Returns a reference to the options mapping.
///
/// # Returns
///
/// A reference to the `HashMap` of outputs.
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
/// Returns a mutable reference to the options mapping.
///
/// # Returns
///
/// A mutable reference to the `HashMap` of outputs.
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
}
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_text_plugin() {
register_meta_plugin(MetaPluginType::Text, |options, outputs| {
Box::new(TextMetaPlugin::new(options, outputs))
})
.expect("Failed to register TextMetaPlugin");
}

325
src/meta_plugin/tokens.rs Normal file
View File

@@ -0,0 +1,325 @@
use crate::common::PIPESIZE;
use crate::common::is_binary::is_binary;
use crate::meta_plugin::{MetaPlugin, MetaPluginResponse, MetaPluginType};
use crate::tokenizer::{TokenEncoding, get_tokenizer};
#[derive(Debug, Clone)]
pub struct TokensMetaPlugin {
/// Buffer for binary detection (up to PIPESIZE bytes).
buffer: Option<Vec<u8>>,
max_buffer_size: usize,
is_finalized: bool,
is_binary_content: Option<bool>,
/// Running token count accumulated across chunks.
token_count: usize,
/// UTF-8 boundary carry buffer.
utf8_buffer: Vec<u8>,
base: crate::meta_plugin::BaseMetaPlugin,
/// The tokenizer encoding.
encoding: TokenEncoding,
}
impl TokensMetaPlugin {
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> Self {
let mut base = crate::meta_plugin::BaseMetaPlugin::new();
base.initialize_plugin(&["token_count"], &options, &outputs);
// Set default options
let default_options = vec![
(
"token_detect_size",
serde_yaml::Value::Number(PIPESIZE.into()),
),
(
"encoding",
serde_yaml::Value::String("cl100k_base".to_string()),
),
];
for (key, value) in default_options {
if !base.options.contains_key(key) {
base.options.insert(key.to_string(), value);
}
}
let max_buffer_size = base
.options
.get("token_detect_size")
.and_then(|v| v.as_u64())
.unwrap_or(PIPESIZE as u64) as usize;
let encoding = base
.options
.get("encoding")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<TokenEncoding>().ok())
.unwrap_or_default();
Self {
buffer: Some(Vec::new()),
max_buffer_size,
is_finalized: false,
is_binary_content: None,
token_count: 0,
utf8_buffer: Vec::new(),
base,
encoding,
}
}
/// Tokenize a byte chunk, handling UTF-8 boundaries.
///
/// Combines with any pending UTF-8 carry bytes, converts to text,
/// and adds the token count to the running total.
///
/// Avoids unnecessary allocations when there is no pending UTF-8 carry
/// and the data is valid UTF-8.
fn count_tokens(&mut self, data: &[u8]) {
if data.is_empty() && self.utf8_buffer.is_empty() {
return;
}
let tokenizer = get_tokenizer(self.encoding);
if self.utf8_buffer.is_empty() {
// Fast path: no pending carry — try to use data directly
match std::str::from_utf8(data) {
Ok(text) => {
if !text.is_empty() {
self.token_count += tokenizer.count(text);
}
return;
}
Err(e) => {
let valid_up_to = e.valid_up_to();
if valid_up_to > 0 {
// Count the valid prefix without copying
let text =
std::str::from_utf8(&data[..valid_up_to]).expect("validated prefix");
self.token_count += tokenizer.count(text);
}
// Save invalid trailing bytes for next call
self.utf8_buffer.extend_from_slice(&data[valid_up_to..]);
return;
}
}
}
// Slow path: pending carry bytes — must build combined buffer
let mut combined = std::mem::take(&mut self.utf8_buffer);
combined.extend_from_slice(data);
match std::str::from_utf8(&combined) {
Ok(text) => {
if !text.is_empty() {
self.token_count += tokenizer.count(text);
}
}
Err(e) => {
let valid_up_to = e.valid_up_to();
if valid_up_to > 0 {
let text =
std::str::from_utf8(&combined[..valid_up_to]).expect("validated prefix");
self.token_count += tokenizer.count(text);
}
self.utf8_buffer.extend_from_slice(&combined[valid_up_to..]);
}
}
}
/// Perform binary detection on the buffer.
fn detect_binary(&mut self, buffer: &[u8]) -> bool {
let result = is_binary(buffer);
self.is_binary_content = Some(result);
result
}
}
impl MetaPlugin for TokensMetaPlugin {
fn is_finalized(&self) -> bool {
self.is_finalized
}
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn update(&mut self, data: &[u8]) -> MetaPluginResponse {
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
if self.is_binary_content.is_none() {
// Add data to the buffer
let should_detect = if let Some(ref mut buffer) = self.buffer {
let remaining = self.max_buffer_size.saturating_sub(buffer.len());
let to_take = std::cmp::min(data.len(), remaining);
buffer.extend_from_slice(&data[..to_take]);
buffer.len() >= std::cmp::min(1024, self.max_buffer_size)
} else {
false
};
if should_detect {
let buffer_data = self.buffer.as_ref().unwrap().clone();
let is_binary = self.detect_binary(&buffer_data);
if is_binary {
if let Some(md) = crate::meta_plugin::process_metadata_outputs(
"token_count",
serde_yaml::Value::Null,
self.base.outputs(),
) {
metadata.push(md);
}
self.buffer = None;
self.is_finalized = true;
return MetaPluginResponse {
metadata,
is_finalized: true,
};
}
// It's text — tokenize the full buffer (nothing was counted yet),
// then clear to avoid double-counting in finalize().
self.count_tokens(&buffer_data);
self.buffer = Some(Vec::new());
}
} else if self.is_binary_content == Some(false) {
self.count_tokens(data);
} else if self.is_binary_content == Some(true) {
self.is_finalized = true;
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
MetaPluginResponse {
metadata,
is_finalized: self.is_finalized,
}
}
fn finalize(&mut self) -> MetaPluginResponse {
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mut metadata = Vec::new();
// If binary detection hasn't completed, do it now
if self.is_binary_content.is_none()
&& let Some(buffer) = &self.buffer
&& !buffer.is_empty()
{
let buffer_data = buffer.clone();
let is_binary = self.detect_binary(&buffer_data);
if is_binary {
if let Some(md) = crate::meta_plugin::process_metadata_outputs(
"token_count",
serde_yaml::Value::Null,
self.base.outputs(),
) {
metadata.push(md);
}
self.buffer = None;
self.is_finalized = true;
return MetaPluginResponse {
metadata,
is_finalized: true,
};
}
}
// Tokenize any bytes in the buffer
if let Some(buffer) = &self.buffer {
let data = buffer.clone();
self.count_tokens(&data);
}
// Process any remaining UTF-8 bytes
if !self.utf8_buffer.is_empty() {
self.count_tokens(&[]);
}
// Emit token count
if let Some(md) = crate::meta_plugin::process_metadata_outputs(
"token_count",
serde_yaml::Value::String(self.token_count.to_string()),
self.base.outputs(),
) {
metadata.push(md);
}
self.buffer = None;
self.is_finalized = true;
MetaPluginResponse {
metadata,
is_finalized: true,
}
}
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::Tokens
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
fn default_outputs(&self) -> Vec<String> {
vec!["token_count".to_string()]
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
fn parallel_safe(&self) -> bool {
true
}
}
use crate::meta_plugin::register_meta_plugin;
#[ctor::ctor]
fn register_tokens_plugin() {
register_meta_plugin(MetaPluginType::Tokens, |options, outputs| {
Box::new(TokensMetaPlugin::new(options, outputs))
})
.expect("Failed to register TokensMetaPlugin");
}

View File

@@ -0,0 +1,173 @@
use crate::common::PIPESIZE;
use crate::meta_plugin::{
BaseMetaPlugin, MetaPlugin, MetaPluginResponse, MetaPluginType, process_metadata_outputs,
register_meta_plugin,
};
#[derive(Debug, Default)]
pub struct TreeMagicMiniMetaPlugin {
buffer: Vec<u8>,
max_buffer_size: usize,
is_finalized: bool,
base: BaseMetaPlugin,
}
impl TreeMagicMiniMetaPlugin {
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> TreeMagicMiniMetaPlugin {
let mut base = BaseMetaPlugin::new();
if let Some(opts) = options {
for (key, value) in opts {
base.options.insert(key, value);
}
}
let max_buffer_size = base
.options
.get("max_buffer_size")
.and_then(|v| v.as_u64())
.unwrap_or(PIPESIZE as u64) as usize;
base.outputs.insert(
"tree_magic_mime_type".to_string(),
serde_yaml::Value::String("tree_magic_mime_type".to_string()),
);
if let Some(outs) = outputs {
for (key, value) in outs {
base.outputs.insert(key, value);
}
}
TreeMagicMiniMetaPlugin {
buffer: Vec::new(),
max_buffer_size,
is_finalized: false,
base,
}
}
}
impl MetaPlugin for TreeMagicMiniMetaPlugin {
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::TreeMagicMini
}
fn is_finalized(&self) -> bool {
self.is_finalized
}
fn set_finalized(&mut self, finalized: bool) {
self.is_finalized = finalized;
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
fn update(&mut self, data: &[u8]) -> MetaPluginResponse {
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let remaining = self.max_buffer_size.saturating_sub(self.buffer.len());
let to_add = &data[..data.len().min(remaining)];
self.buffer.extend_from_slice(to_add);
if self.buffer.len() >= self.max_buffer_size {
let mime_type = tree_magic_mini::from_u8(&self.buffer);
self.is_finalized = true;
let metadata = process_metadata_outputs(
"tree_magic_mime_type",
serde_yaml::Value::String(mime_type.to_string()),
self.base.outputs(),
)
.map(|m| vec![m])
.unwrap_or_default();
return MetaPluginResponse {
metadata,
is_finalized: true,
};
}
MetaPluginResponse {
metadata: Vec::new(),
is_finalized: false,
}
}
fn finalize(&mut self) -> MetaPluginResponse {
if self.is_finalized {
return MetaPluginResponse {
metadata: Vec::new(),
is_finalized: true,
};
}
let mime_type = tree_magic_mini::from_u8(&self.buffer);
self.is_finalized = true;
let metadata = process_metadata_outputs(
"tree_magic_mime_type",
serde_yaml::Value::String(mime_type.to_string()),
self.base.outputs(),
)
.map(|m| vec![m])
.unwrap_or_default();
MetaPluginResponse {
metadata,
is_finalized: true,
}
}
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
fn default_outputs(&self) -> Vec<String> {
vec!["tree_magic_mime_type".to_string()]
}
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
fn parallel_safe(&self) -> bool {
true
}
}
#[ctor::ctor]
fn register_tree_magic_mini_plugin() {
register_meta_plugin(MetaPluginType::TreeMagicMini, |options, outputs| {
Box::new(TreeMagicMiniMetaPlugin::new(options, outputs))
})
.expect("Failed to register TreeMagicMiniMetaPlugin");
}

179
src/meta_plugin/user.rs Normal file
View File

@@ -0,0 +1,179 @@
use crate::meta_plugin::{MetaPlugin, MetaPluginType};
#[derive(Debug, Clone, Default)]
/// Meta plugin for capturing current user and group information.
///
/// This plugin collects user ID, group ID, username, and group name for the process
/// running the keep application, providing context about the creator of items.
pub struct UserMetaPlugin {
base: crate::meta_plugin::BaseMetaPlugin,
}
impl UserMetaPlugin {
/// Creates a new `UserMetaPlugin` instance.
///
/// # Arguments
///
/// * `options` - Optional configuration options for the plugin.
/// * `outputs` - Optional output mappings for metadata.
///
/// # Returns
///
/// A new instance of `UserMetaPlugin`.
pub fn new(
options: Option<std::collections::HashMap<String, serde_yaml::Value>>,
outputs: Option<std::collections::HashMap<String, serde_yaml::Value>>,
) -> UserMetaPlugin {
let mut base = crate::meta_plugin::BaseMetaPlugin::new();
// Initialize with helper function
base.initialize_plugin(
&["user_uid", "user_gid", "user_name", "user_group"],
&options,
&outputs,
);
UserMetaPlugin { base }
}
/// Gets the current username.
///
/// # Returns
///
/// An `Option<String>` with the username, or `None` if unavailable.
fn get_current_username() -> Option<String> {
uzers::get_user_by_uid(uzers::get_current_uid())
.map(|user| user.name().to_string_lossy().to_string())
}
/// Gets the current group name.
///
/// # Returns
///
/// An `Option<String>` with the group name, or `None` if unavailable.
fn get_current_groupname() -> Option<String> {
uzers::get_group_by_gid(uzers::get_current_gid())
.map(|group| group.name().to_string_lossy().to_string())
}
}
impl MetaPlugin for UserMetaPlugin {
/// Initializes the plugin, capturing user information.
///
/// # Returns
///
/// A `MetaPluginResponse` with user metadata and `is_finalized` set to `true`.
fn initialize(&mut self) -> crate::meta_plugin::MetaPluginResponse {
let mut metadata = Vec::new();
// Get user info
let uid = uzers::get_current_uid().to_string();
let gid = uzers::get_current_gid().to_string();
let username = Self::get_current_username().unwrap_or_else(|| "unknown".to_string());
let groupname = Self::get_current_groupname().unwrap_or_else(|| "unknown".to_string());
// Process each output
let values = [
("user_uid", uid),
("user_gid", gid),
("user_name", username),
("user_group", groupname),
];
for (name, value) in values {
if let Some(meta_data) = crate::meta_plugin::process_metadata_outputs(
name,
serde_yaml::Value::String(value),
self.base.outputs(),
) {
metadata.push(meta_data);
}
}
crate::meta_plugin::MetaPluginResponse {
metadata,
is_finalized: true,
}
}
/// Returns the type of this meta plugin.
///
/// # Returns
///
/// `MetaPluginType::User`.
fn meta_type(&self) -> MetaPluginType {
MetaPluginType::User
}
fn set_save_meta(&mut self, save_meta: crate::meta_plugin::SaveMetaFn) {
self.base.set_save_meta(save_meta);
}
fn save_meta(&self, name: &str, value: &str) {
self.base.save_meta(name, value);
}
/// Returns a reference to the outputs mapping.
///
/// # Returns
///
/// A reference to the `HashMap` of outputs.
fn outputs(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.outputs()
}
/// Returns a mutable reference to the outputs mapping.
///
/// # Returns
///
/// A mutable reference to the `HashMap` of outputs.
fn outputs_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.outputs_mut())
}
/// Returns the default output names.
///
/// # Returns
///
/// A vector of default output names.
fn default_outputs(&self) -> Vec<String> {
vec![
"user_uid".to_string(),
"user_gid".to_string(),
"user_name".to_string(),
"user_group".to_string(),
]
}
/// Returns a reference to the options mapping.
///
/// # Returns
///
/// A reference to the `HashMap` of options.
fn options(&self) -> &std::collections::HashMap<String, serde_yaml::Value> {
self.base.options()
}
/// Returns a mutable reference to the options mapping.
///
/// # Returns
///
/// A mutable reference to the `HashMap` of options.
fn options_mut(
&mut self,
) -> anyhow::Result<&mut std::collections::HashMap<String, serde_yaml::Value>> {
Ok(self.base.options_mut())
}
}
use crate::meta_plugin::register_meta_plugin;
// Register the plugin at module initialization time
#[ctor::ctor]
fn register_user_plugin() {
register_meta_plugin(MetaPluginType::User, |options, outputs| {
Box::new(UserMetaPlugin::new(options, outputs))
})
.expect("Failed to register UserMetaPlugin");
}

View File

@@ -0,0 +1,21 @@
use crate::client::KeepClient;
use clap::Command;
use log::debug;
pub fn mode(
client: &KeepClient,
_cmd: &mut Command,
settings: &crate::config::Settings,
ids: &[i64],
) -> Result<(), anyhow::Error> {
debug!("CLIENT_DELETE: Deleting items via remote server");
for &id in ids {
client.delete_item(id)?;
if !settings.quiet {
eprintln!("Deleted item {id}");
}
}
Ok(())
}

24
src/modes/client/diff.rs Normal file
View File

@@ -0,0 +1,24 @@
use crate::client::KeepClient;
use clap::Command;
use log::debug;
pub fn mode(
client: &KeepClient,
_cmd: &mut Command,
_settings: &crate::config::Settings,
ids: &[i64],
) -> Result<(), anyhow::Error> {
debug!("CLIENT_DIFF: Getting diff via remote server");
if ids.len() != 2 {
return Err(anyhow::anyhow!("Diff requires exactly 2 item IDs"));
}
let diff_lines = client.diff_items(ids[0], ids[1])?;
for line in &diff_lines {
println!("{line}");
}
Ok(())
}

View File

@@ -0,0 +1,77 @@
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use clap::Command;
use log::debug;
use std::collections::HashMap;
use std::fs;
use crate::client::KeepClient;
use crate::common::sanitize_ts_string;
use crate::config;
/// Export items to a `.keep.tar` archive via client.
///
/// Sends a request to the server's `/api/export` endpoint and
/// streams the response to a local tar file.
pub fn mode(
client: &KeepClient,
cmd: &mut Command,
settings: &config::Settings,
ids: &[i64],
tags: &[String],
) -> Result<()> {
// Validate: IDs XOR tags
if !ids.is_empty() && !tags.is_empty() {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"Cannot use both IDs and tags with --export",
)
.exit();
}
if ids.is_empty() && tags.is_empty() {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"Must provide either IDs or tags with --export",
)
.exit();
}
// We need to resolve items on the server to compute the filename.
// First, get the item info to build the filename template variables.
// For the tar filename, we use {name}_{ts}.keep.tar where name comes from
// --export-name or default export_<common-tags>.
let dir_name = if let Some(ref name) = settings.export_name {
name.clone()
} else {
"export".to_string()
};
let now = Utc::now();
let ts_str = sanitize_ts_string(&now.format("%Y-%m-%dT%H:%M:%SZ").to_string());
let mut vars = HashMap::new();
vars.insert("name".to_string(), dir_name);
vars.insert("ts".to_string(), ts_str);
let basename = strfmt::strfmt(&settings.export_filename_format, &vars).map_err(|e| {
anyhow!(
"Invalid export filename format '{}': {}",
settings.export_filename_format,
e
)
})?;
let tar_filename = format!("{basename}.keep.tar");
client
.export_items_to_file(ids, tags, std::path::Path::new(&tar_filename))
.map_err(|e| anyhow!("Export failed: {e}"))?;
if !settings.quiet {
eprintln!("{tar_filename}");
}
debug!("CLIENT_EXPORT: Wrote items to {tar_filename}");
Ok(())
}

75
src/modes/client/get.rs Normal file
View File

@@ -0,0 +1,75 @@
use crate::client::KeepClient;
use crate::compression_engine::CompressionType;
use crate::filter_plugin::FilterChain;
use crate::modes::common::{check_binary_tty, resolve_item_id};
use crate::services::compression_service::CompressionService;
use anyhow::Result;
use clap::Command;
use log::debug;
use std::io::{Read, Write};
use std::str::FromStr;
pub fn mode(
client: &KeepClient,
cmd: &mut Command,
settings: &crate::config::Settings,
ids: &[i64],
tags: &[String],
filter_chain: Option<FilterChain>,
) -> Result<(), anyhow::Error> {
debug!("CLIENT_GET: Getting item via remote server");
if !ids.is_empty() && !tags.is_empty() {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"Both ID and tags given, you must supply either IDs or tags when using --get",
)
.exit();
}
let item_id = resolve_item_id(client, ids, tags)?;
// Get item info for metadata
let item_info = client.get_item_info(item_id)?;
let metadata = &item_info.metadata;
// Get streaming reader for raw content
let (reader, compression) = client.get_item_content_stream(item_id)?;
let compression_type = CompressionType::from_str(&compression).unwrap_or(CompressionType::Raw);
// Decompress through streaming readers
let mut decompressed_reader: Box<dyn Read> =
CompressionService::decompressing_reader(reader, &compression_type)?;
// Binary detection: sample first chunk
let mut sample_buf = [0u8; crate::common::PIPESIZE];
let sample_len = decompressed_reader.read(&mut sample_buf)?;
check_binary_tty(metadata, &sample_buf[..sample_len], settings.force)?;
// If filters present, buffer through filter chain; otherwise stream directly
if let Some(mut chain) = filter_chain {
// Apply filter to sample first, then remaining
let mut output = Vec::new();
chain.filter(&mut &sample_buf[..sample_len], &mut output)?;
crate::common::stream_copy(&mut decompressed_reader, |chunk| {
chain.filter(&mut std::io::Cursor::new(chunk), &mut output)?;
Ok(())
})?;
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
stdout.write_all(&output)?;
stdout.flush()?;
} else {
// Stream decompressed content to stdout
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
stdout.write_all(&sample_buf[..sample_len])?;
crate::common::stream_copy(&mut decompressed_reader, |chunk| {
stdout.write_all(chunk)?;
Ok(())
})?;
stdout.flush()?;
}
Ok(())
}

160
src/modes/client/import.rs Normal file
View File

@@ -0,0 +1,160 @@
use anyhow::{Context, Result, anyhow};
use clap::Command;
use log::debug;
use std::collections::HashMap;
use std::fs;
use std::io::Read;
use std::path::Path;
use crate::client::KeepClient;
use crate::compression_engine::CompressionType;
use crate::config;
use crate::modes::common::ImportMeta;
use std::str::FromStr;
/// Import items from a `.keep.tar` archive or legacy `.meta.yml` file via client.
///
/// For `.keep.tar` files, streams the archive to the server's `/api/import` endpoint.
/// For `.meta.yml` files, uses the legacy single-item import path.
pub fn mode(
client: &KeepClient,
cmd: &mut Command,
settings: &config::Settings,
import_path: &str,
) -> Result<()> {
if import_path.ends_with(".keep.tar") {
import_tar(client, cmd, settings, import_path)
} else if import_path.ends_with(".meta.yml") {
import_legacy(client, cmd, settings, import_path)
} else {
cmd.error(
clap::error::ErrorKind::InvalidValue,
format!("Unsupported import format: {}", import_path),
)
.exit();
}
}
/// Import from a `.keep.tar` archive via the server API.
fn import_tar(
client: &KeepClient,
_cmd: &mut Command,
settings: &config::Settings,
tar_path: &str,
) -> Result<()> {
let path = Path::new(tar_path);
let imported_ids = client
.import_tar_file(path)
.map_err(|e| anyhow!("Import failed: {e}"))?;
if !settings.quiet {
println!(
"KEEP: Imported {} item(s): {:?}",
imported_ids.len(),
imported_ids
);
}
debug!(
"CLIENT_IMPORT: Imported {} items from {}",
imported_ids.len(),
tar_path
);
Ok(())
}
/// Legacy single-item import from a `.meta.yml` file.
fn import_legacy(
client: &KeepClient,
cmd: &mut Command,
settings: &config::Settings,
meta_file: &str,
) -> Result<()> {
// Read and parse metadata
let meta_yaml = fs::read_to_string(meta_file)
.with_context(|| format!("Cannot read metadata file: {meta_file}"))?;
let import_meta: ImportMeta = serde_yaml::from_str(&meta_yaml)
.with_context(|| format!("Cannot parse metadata file: {meta_file}"))?;
// Validate compression type
CompressionType::from_str(&import_meta.compression).map_err(|_| {
anyhow!(
"Invalid compression type '{}' in metadata file",
import_meta.compression
)
})?;
debug!(
"CLIENT_IMPORT: Parsed meta: ts={}, compression={}, tags={:?}",
import_meta.ts, import_meta.compression, import_meta.tags
);
// Build query parameters
let ts_str = import_meta.ts.to_rfc3339();
let params = [
("compress".to_string(), "false".to_string()),
("meta".to_string(), "false".to_string()),
("tags".to_string(), import_meta.tags.join(",")),
(
"compression_type".to_string(),
import_meta.compression.clone(),
),
("ts".to_string(), ts_str),
];
let param_refs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
// Stream data to server without buffering entire file
let item_info = if let Some(ref data_file) = settings.import_data_file {
let mut reader = fs::File::open(data_file)
.with_context(|| format!("Cannot read data file: {}", data_file.display()))?;
client.post_stream("/api/item/", &mut reader, &param_refs)?
} else {
// For stdin, we need to buffer since stdin can't be seeked
// and post_stream may need to retry.
let mut buf = Vec::new();
std::io::stdin()
.read_to_end(&mut buf)
.context("Cannot read data from stdin")?;
if buf.is_empty() {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"No data provided (empty stdin)",
)
.exit();
}
let mut cursor = std::io::Cursor::new(&buf);
client.post_stream("/api/item/", &mut cursor, &param_refs)?
};
let item_id = item_info.id;
debug!("CLIENT_IMPORT: Created item {} via server", item_id);
// Set uncompressed size if known from metadata
if let Some(size) = import_meta.uncompressed_size {
client.set_item_size(item_id, size as u64)?;
debug!("CLIENT_IMPORT: Set size to {}", size);
}
// Post metadata
if !import_meta.metadata.is_empty() {
client.post_metadata(item_id, &import_meta.metadata)?;
debug!(
"CLIENT_IMPORT: Set {} metadata entries",
import_meta.metadata.len()
);
}
if !settings.quiet {
println!(
"KEEP: Imported item {} tags: {:?}",
item_id, import_meta.tags
);
}
Ok(())
}

52
src/modes/client/info.rs Normal file
View File

@@ -0,0 +1,52 @@
use crate::client::KeepClient;
use crate::modes::common::{
DisplayItemInfo, OutputFormat, format_size, render_item_info_table, resolve_item_ids,
settings_output_format,
};
use clap::Command;
use log::debug;
pub fn mode(
client: &KeepClient,
_cmd: &mut Command,
settings: &crate::config::Settings,
ids: &[i64],
tags: &[String],
) -> Result<(), anyhow::Error> {
debug!("CLIENT_INFO: Getting item info via remote server");
let output_format = settings_output_format(settings);
let item_ids = resolve_item_ids(client, ids, tags)?;
for &id in &item_ids {
let item = client.get_item_info(id)?;
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
crate::modes::common::print_serialized(&item, &output_format)?;
}
OutputFormat::Table => {
let display = DisplayItemInfo {
id: item.id,
timestamp: item.ts.clone(),
path: String::new(),
stream_size: item
.uncompressed_size
.map(|s| format_size(s as u64, settings.human_readable))
.unwrap_or_else(|| "N/A".to_string()),
compression: item.compression.clone(),
file_size: String::new(),
tags: item.tags.clone(),
metadata: item
.metadata
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
};
render_item_info_table(&display, &settings.table_config);
}
}
}
Ok(())
}

71
src/modes/client/list.rs Normal file
View File

@@ -0,0 +1,71 @@
use crate::client::KeepClient;
use crate::modes::common::{
ColumnType, OutputFormat, format_size, render_list_table_with_format, settings_output_format,
};
use clap::Command;
use log::debug;
use std::str::FromStr;
pub fn mode(
client: &KeepClient,
_cmd: &mut Command,
settings: &crate::config::Settings,
ids: &[i64],
tags: &[String],
) -> Result<(), anyhow::Error> {
debug!("CLIENT_LIST: Listing items via remote server");
let items = client.list_items(ids, tags, "newest", 0, 100, &settings.meta_filter())?;
if settings.ids_only {
for item in &items {
println!("{}", item.id);
}
return Ok(());
}
let output_format = settings_output_format(settings);
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
crate::modes::common::print_serialized(&items, &output_format)?;
}
OutputFormat::Table => {
let rows: Vec<Vec<String>> = items
.iter()
.map(|item| {
let mut row = Vec::new();
for column in &settings.list_format {
let col_type = ColumnType::from_str(&column.name).ok();
let cell = match col_type {
Some(ColumnType::Id) => item.id.to_string(),
Some(ColumnType::Time) => item.ts.clone(),
Some(ColumnType::Size) => item
.uncompressed_size
.map(|s| format_size(s as u64, settings.human_readable))
.unwrap_or_default(),
Some(ColumnType::Compression) => item.compression.clone(),
Some(ColumnType::Tags) => item.tags.join(" "),
Some(ColumnType::Meta) => {
let meta_key = column.name.strip_prefix("meta:");
match meta_key {
Some(key) => {
item.metadata.get(key).cloned().unwrap_or_default()
}
None => String::new(),
}
}
_ => String::new(),
};
row.push(cell);
}
row
})
.collect();
render_list_table_with_format(&settings.list_format, &rows, &settings.table_config);
}
}
Ok(())
}

10
src/modes/client/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
pub mod delete;
pub mod diff;
pub mod export;
pub mod get;
pub mod import;
pub mod info;
pub mod list;
pub mod save;
pub mod status;
pub mod update;

180
src/modes/client/save.rs Normal file
View File

@@ -0,0 +1,180 @@
use crate::client::KeepClient;
use crate::compression_engine::CompressionType;
use crate::config::Settings;
use crate::meta_plugin::SaveMetaFn;
use crate::modes::common::settings_compression_type;
use crate::services::ItemInfo;
use crate::services::compression_service::CompressionService;
use crate::services::meta_service::MetaService;
use anyhow::Result;
use clap::Command;
use is_terminal::IsTerminal;
use log::debug;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
/// Streaming save mode for client.
///
/// Uses three threads for true streaming with constant memory:
/// - Reader thread: reads stdin, tees to stdout, runs meta plugins,
/// compresses data, writes to OS pipe
/// - Pipe: zero-copy transfer of compressed bytes between threads
/// - Streamer thread: reads from pipe, streams to server via chunked HTTP
///
/// Meta plugins run on the client side during streaming. Collected metadata
/// is sent to the server via a separate POST after streaming completes.
///
/// Memory usage is O(PIPESIZE) regardless of data size.
pub fn mode(
client: &KeepClient,
cmd: &mut Command,
settings: &Settings,
tags: &mut Vec<String>,
metadata: HashMap<String, String>,
) -> Result<(), anyhow::Error> {
debug!("CLIENT_SAVE: Saving item via remote server (streaming)");
crate::modes::common::ensure_default_tag(tags);
// Determine compression type from settings
let compression_type = settings_compression_type(cmd, settings);
let compression_type_str = compression_type.to_string();
// In client mode, the client always handles compression (even "raw").
// The server should never re-compress client data.
let server_compress = false;
// Shared metadata collection: plugins write here via save_meta closure
let collected_meta: Arc<Mutex<HashMap<String, String>>> = Arc::new(Mutex::new(HashMap::new()));
let meta_collector = collected_meta.clone();
let save_meta: SaveMetaFn = Arc::new(Mutex::new(move |name: &str, value: &str| {
if let Ok(mut map) = meta_collector.lock() {
map.insert(name.to_string(), value.to_string());
}
}));
// Create MetaService and get plugins (must happen before spawning reader thread)
let meta_service = MetaService::new(save_meta);
let mut plugins = meta_service.get_plugins(cmd, settings);
// Create OS pipe for streaming compressed bytes between threads
let (pipe_reader, pipe_writer) = os_pipe::pipe()?;
// Reader thread: stdin → tee(stdout) → meta plugins → compress → pipe
let compression_type_clone = compression_type.clone();
let reader_handle = std::thread::spawn(move || -> Result<u64> {
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let mut stdin_lock = stdin.lock();
let mut stdout_lock = stdout.lock();
let mut total_bytes = 0u64;
let mut buffer = [0u8; 8192];
// Initialize meta plugins
meta_service.initialize_plugins(&mut plugins);
// Wrap pipe writer with appropriate compression
let mut compressor: Box<dyn Write> =
CompressionService::compressing_writer(Box::new(pipe_writer), &compression_type_clone)?;
loop {
let n = stdin_lock.read(&mut buffer)?;
if n == 0 {
break;
}
// Tee to stdout
stdout_lock.write_all(&buffer[..n])?;
// Feed chunk to meta plugins
meta_service.process_chunk(&mut plugins, &buffer[..n]);
total_bytes += n as u64;
// Compress and write to pipe
compressor.write_all(&buffer[..n])?;
}
// Finalize meta plugins (digest, text, tokens produce final output here)
meta_service.finalize_plugins(&mut plugins);
// Explicitly flush and finalize compression before dropping.
compressor.flush()?;
drop(compressor);
Ok(total_bytes)
});
// Streamer thread: reads compressed bytes from pipe → POST to server
let client_url = client.base_url().to_string();
let client_username = client.username().cloned();
let client_password = client.password().cloned();
let client_jwt = client.jwt().cloned();
let tags_clone = tags.clone();
let compression_type_str_clone = compression_type_str.clone();
let streamer_handle = std::thread::spawn(move || -> Result<ItemInfo> {
let streaming_client =
KeepClient::new(&client_url, client_username, client_password, client_jwt)?;
let params = [
("compress".to_string(), server_compress.to_string()),
("meta".to_string(), "false".to_string()),
("tags".to_string(), tags_clone.join(",")),
// Always send compression_type when compress=false (client handled compression)
("compression_type".to_string(), compression_type_str_clone),
];
// Filter out empty params
let params: Vec<(String, String)> =
params.into_iter().filter(|(_, v)| !v.is_empty()).collect();
let param_refs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let mut reader: Box<dyn Read> = Box::new(pipe_reader);
let item_info = streaming_client.post_stream("/api/item/", &mut reader, &param_refs)?;
Ok(item_info)
});
// Wait for streaming to complete, capture item info
let item_info = streamer_handle
.join()
.map_err(|e| anyhow::anyhow!("Streamer thread panicked: {:?}", e))??;
// Wait for reader thread (should complete quickly after pipe is drained)
let uncompressed_size = reader_handle
.join()
.map_err(|e| anyhow::anyhow!("Reader thread panicked: {:?}", e))??;
// Merge plugin-collected metadata with CLI metadata
let mut local_metadata = metadata;
// Add plugin-collected metadata (digest, hostname, text stats, etc.)
if let Ok(plugin_meta) = collected_meta.lock() {
for (k, v) in plugin_meta.iter() {
local_metadata.entry(k.clone()).or_insert_with(|| v.clone());
}
}
// Send uncompressed size to server (proper field, not metadata)
client.set_item_size(item_info.id, uncompressed_size)?;
// Send metadata to server
if !local_metadata.is_empty() {
client.post_metadata(item_info.id, &local_metadata)?;
}
// Print status to stderr (item ID is known immediately from server response)
if !settings.quiet {
if std::io::stderr().is_terminal() {
eprintln!("KEEP: New item: {} tags: {}", item_info.id, tags.join(" "));
} else {
eprintln!("KEEP: New item: {} tags: {tags:?}", item_info.id);
}
}
debug!("CLIENT_SAVE: Streaming complete, {uncompressed_size} bytes uncompressed");
Ok(())
}

View File

@@ -0,0 +1,91 @@
use crate::client::KeepClient;
use crate::modes::common::OutputFormat;
use crate::modes::common::settings_output_format;
use clap::Command;
use comfy_table::{Attribute, Cell, Table};
use log::debug;
pub fn mode(
client: &KeepClient,
_cmd: &mut Command,
settings: &crate::config::Settings,
) -> Result<(), anyhow::Error> {
debug!("CLIENT_STATUS: Getting status from remote server");
let status_info = client.get_status()?;
let output_format = settings_output_format(settings);
match output_format {
OutputFormat::Json | OutputFormat::Yaml => {
crate::modes::common::print_serialized(&status_info, &output_format)?;
}
OutputFormat::Table => {
// Paths
let path_table =
crate::modes::common::build_path_table(&status_info.paths, &settings.table_config);
println!("PATHS:");
println!(
"{}",
crate::modes::common::trim_lines_end(&path_table.trim_fmt())
);
println!();
// Configured meta plugins
if let Some(ref configured) = status_info.configured_meta_plugins
&& !configured.is_empty()
{
let mut sorted = configured.clone();
sorted.sort_by(|a, b| a.name.cmp(&b.name));
let mut table =
crate::modes::common::create_table_with_config(&settings.table_config);
table.set_header(vec![
Cell::new("Plugin Name").add_attribute(Attribute::Bold),
Cell::new("Enabled").add_attribute(Attribute::Bold),
]);
for plugin in &sorted {
let enabled = status_info.enabled_meta_plugins.contains(&plugin.name);
table.add_row(vec![
plugin.name.clone(),
if enabled { "Yes" } else { "No" }.to_string(),
]);
}
println!("META PLUGINS:");
println!(
"{}",
crate::modes::common::trim_lines_end(&table.trim_fmt())
);
println!();
}
// Compression
if !status_info.compression.is_empty() {
let mut table =
crate::modes::common::create_table_with_config(&settings.table_config);
table.set_header(vec![
Cell::new("Type").add_attribute(Attribute::Bold),
Cell::new("Found").add_attribute(Attribute::Bold),
Cell::new("Default").add_attribute(Attribute::Bold),
Cell::new("Binary").add_attribute(Attribute::Bold),
]);
for comp in &status_info.compression {
table.add_row(vec![
comp.compression_type.clone(),
if comp.found { "Yes" } else { "No" }.to_string(),
if comp.default { "Yes" } else { "No" }.to_string(),
comp.binary.clone(),
]);
}
println!("COMPRESSION:");
println!(
"{}",
crate::modes::common::trim_lines_end(&table.trim_fmt())
);
println!();
}
}
}
Ok(())
}

102
src/modes/client/update.rs Normal file
View File

@@ -0,0 +1,102 @@
use crate::client::KeepClient;
use crate::config::Settings;
use anyhow::Result;
use clap::Command;
use log::debug;
use std::collections::HashMap;
/// Client update mode: runs meta plugins on the server for an existing item.
///
/// Sends the list of plugin names (from --meta-plugin config) and any direct
/// metadata (--meta key=value) to the server. The server reads the stored file,
/// runs the specified plugins, and stores the results.
pub fn mode(
client: &KeepClient,
cmd: &mut Command,
settings: &Settings,
ids: &mut [i64],
tags: &mut [String],
) -> Result<(), anyhow::Error> {
debug!("CLIENT_UPDATE: Updating item via remote server");
if ids.len() != 1 {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"--update requires exactly one numeric ID",
)
.exit();
}
let item_id = ids[0];
// Collect plugin names from settings (--meta-plugin config)
let plugin_names: Vec<String> = settings
.meta_plugins_names()
.into_iter()
.flat_map(|s| {
s.split(',')
.map(|p| p.trim().to_string())
.collect::<Vec<_>>()
})
.filter(|p| !p.is_empty())
.collect();
// Collect direct metadata from --meta flags
let metadata: HashMap<String, String> = settings
.meta
.iter()
.filter_map(|(k, v)| v.as_ref().map(|val| (k.clone(), val.clone())))
.collect();
// Build query params
let mut params: Vec<(String, String)> = Vec::new();
if !plugin_names.is_empty() {
params.push(("plugins".to_string(), plugin_names.join(",")));
}
if !metadata.is_empty() {
let meta_json = serde_json::to_string(&metadata)?;
params.push(("metadata".to_string(), meta_json));
}
if !tags.is_empty() {
params.push(("tags".to_string(), tags.join(",")));
}
// Nothing to update
if params.is_empty() {
if !settings.quiet {
eprintln!("KEEP: No changes specified for item {item_id}");
}
return Ok(());
}
let param_refs: Vec<(&str, &str)> = params
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let url_path = format!("/api/item/{item_id}/update");
// POST to update endpoint
let _item_info = client.post_bytes(&url_path, &[], &param_refs)?;
if !settings.quiet {
let mut parts = Vec::new();
if !plugin_names.is_empty() {
parts.push(format!("plugins: {}", plugin_names.join(", ")));
}
if !metadata.is_empty() {
parts.push(format!("{} metadata", metadata.len()));
}
if !tags.is_empty() {
parts.push(format!("tags: {}", tags.join(" ")));
}
let action = parts.join(", ");
eprintln!("KEEP: Updated item {item_id} ({action})");
}
Ok(())
}

View File

@@ -1,83 +1,130 @@
use crate::Args;
use crate::common::status::PathInfo;
use crate::compression_engine::CompressionType;
/// Common utilities shared across different modes in the Keep application.
///
/// This module provides helper functions for formatting, configuration parsing,
/// table creation, and environment variable handling used by various CLI modes.
///
/// # Usage
///
/// These utilities are typically used internally by mode implementations:
///
/// ```
/// # use keep::modes::common::{format_size, OutputFormat};
/// let formatted = format_size(1024, true); // "1.0K"
/// // let format = OutputFormat::from_str("json")?;
/// ```
use crate::config;
use crate::meta_plugin::MetaPluginType;
use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
use clap::Command;
use clap::error::ErrorKind;
use comfy_table::{Attribute, Cell, ContentArrangement, Table};
use log::debug;
use prettytable::format::TableFormat;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::io::IsTerminal;
use std::str::FromStr;
use strum::IntoEnumIterator;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, strum::EnumString, strum::Display, PartialEq)]
#[strum(ascii_case_insensitive)]
/// Enum representing supported output formats for structured data.
///
/// Used to determine how to display lists, info, and status information in CLI modes.
/// Defaults to Table for human-readable output; JSON/YAML for machine parsing.
///
/// # Variants
///
/// * `Table` - Formatted table output (default).
/// * `Json` - JSON structured output.
/// * `Yaml` - YAML structured output.
///
/// # Examples
///
/// ```
/// # use keep::modes::common::OutputFormat;
/// # use std::str::FromStr;
/// assert_eq!(OutputFormat::from_str("json").unwrap(), OutputFormat::Json);
/// ```
pub enum OutputFormat {
Table,
Json,
Yaml,
}
pub const IMPORT_FORMAT_ERROR: &str =
"Unsupported import format: {} (expected .keep.tar or .meta.yml)";
pub fn get_meta_from_env() -> HashMap<String, String> {
debug!("COMMON: Getting meta from KEEP_META_*");
let re = Regex::new(r"^KEEP_META_(.+)$").unwrap();
let mut meta_env: HashMap<String, String> = HashMap::new();
const PREFIX: &str = "KEEP_META_";
for (key, value) in env::vars() {
if let Some(meta_name_caps) = re.captures(key.as_str()) {
let name = String::from(meta_name_caps.get(1).unwrap().as_str());
// Ignore KEEP_META_PLUGINS
if name != "PLUGINS" {
debug!("COMMON: Found meta: {}={}", name.clone(), value.clone());
meta_env.insert(name, value.clone());
if let Some(name) = key.strip_prefix(PREFIX) {
if !name.is_empty() && name != "PLUGINS" {
debug!("COMMON: Found meta: {}={}", name, value);
meta_env.insert(name.to_string(), value);
}
}
}
meta_env
}
pub fn format_size_human_readable(size: u64) -> String {
const UNITS: &[&str] = &["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei"];
const THRESHOLD: u64 = 1024;
if size == 0 {
return "0".to_string();
}
let mut size_f = size as f64;
let mut unit_index = 0;
while size_f >= THRESHOLD as f64 && unit_index < UNITS.len() - 1 {
size_f /= THRESHOLD as f64;
unit_index += 1;
}
if unit_index == 0 {
format!("{}", size)
} else if size_f.fract() == 0.0 {
format!("{}{}", size_f as u64, UNITS[unit_index])
} else {
format!("{:.1}{}", size_f, UNITS[unit_index])
}
}
/// Formats a file size in bytes to human-readable or raw format.
///
/// Uses the humansize crate for human-readable output with decimal units (KB, MB, etc.).
///
/// # Arguments
///
/// * `size` - Size in bytes as u64.
/// * `human_readable` - If true, use units like KB, MB; otherwise, raw bytes as string.
///
/// # Returns
///
/// `String` - Formatted size string, e.g., "1.0K" or "1024".
///
/// # Examples
///
/// ```
/// # use keep::modes::common::format_size;
/// let raw = format_size(1024, false); // "1024"
/// let human = format_size(1024, true); // "1.0K"
/// ```
pub fn format_size(size: u64, human_readable: bool) -> String {
match human_readable {
true => format_size_human_readable(size),
true => humansize::format_size(size, humansize::DECIMAL),
false => size.to_string(),
}
}
pub fn string_column(s: String, column_width: usize) -> String {
if column_width > 0 {
match s.char_indices().nth(column_width) {
None => s.to_string(),
Some((idx, _)) => s[..idx].to_string(),
}
} else {
s.to_string()
}
}
pub fn size_column(size: u64, human_readable: bool, column_width: usize) -> String {
string_column(format_size(size, human_readable), column_width)
}
#[derive(Debug, Eq, PartialEq, Clone, strum::EnumIter, strum::Display, strum::EnumString)]
#[derive(Debug, Eq, PartialEq, Clone, strum::EnumIter, strum::Display)]
#[strum(ascii_case_insensitive)]
/// Enum representing column types for table display.
///
/// Defines standard and meta columns for list/info modes. Supports "meta:<name>" for specific metadata columns.
///
/// # Variants
///
/// * `Id` - Item ID column.
/// * `Time` - Timestamp column.
/// * `Size` - Content size column.
/// * `Compression` - Compression type column.
/// * `FileSize` - On-disk file size column.
/// * `FilePath` - File path column.
/// * `Tags` - Tags column.
/// * `Meta` - Metadata column (with sub-type via string parsing).
///
/// # Examples
///
/// ```
/// # use keep::modes::common::ColumnType;
/// # use std::str::FromStr;
/// assert_eq!(ColumnType::from_str("id").unwrap(), ColumnType::Id);
/// assert_eq!(ColumnType::from_str("meta:hostname").unwrap(), ColumnType::Meta);
/// ```
pub enum ColumnType {
Id,
Time,
@@ -89,134 +136,73 @@ pub enum ColumnType {
Meta,
}
impl ColumnType {
/// Returns a Result with error message if the string is not a valid ColumnType
pub fn from_str(s: &str) -> anyhow::Result<Self> {
Ok(Self::try_from(s)?)
}
}
// impl TryFrom<&str> for ColumnType is already implemented by strum_macros
// so we remove this conflicting implementation
pub fn get_format_box_chars_no_border_line_separator() -> TableFormat {
prettytable::format::FormatBuilder::new()
.column_separator('│')
.borders('│')
.separators(
&[prettytable::format::LinePosition::Top],
prettytable::format::LineSeparator::new('─', '┬', '┌', '┐'),
)
.separators(
&[prettytable::format::LinePosition::Title],
prettytable::format::LineSeparator::new('─', '┼', '├', '┤'),
)
.separators(
&[prettytable::format::LinePosition::Bottom],
prettytable::format::LineSeparator::new('─', '┴', '└', '┘'),
)
.padding(1, 1)
.build()
}
pub fn get_digest_type_meta(digest_type: MetaPluginType) -> String {
format!("digest_{}", digest_type.to_string().to_lowercase())
}
pub fn cmd_args_digest_type(cmd: &mut Command, args: &Args) -> MetaPluginType {
let digest_name = args
.item
.digest
.clone()
.unwrap_or(MetaPluginType::DigestSha256.to_string());
let digest_type_opt = MetaPluginType::from_str(&digest_name);
if digest_type_opt.is_err() {
cmd.error(
ErrorKind::InvalidValue,
format!("Invalid digest algorithm '{}'. Use 'sha256' or 'md5'", digest_name),
)
.exit();
}
digest_type_opt.unwrap()
}
pub fn cmd_args_compression_type(cmd: &mut Command, args: &Args) -> CompressionType {
let compression_name = args
.item
.compression
.clone()
.unwrap_or(CompressionType::LZ4.to_string());
let compression_type_opt = CompressionType::from_str(&compression_name);
if compression_type_opt.is_err() {
cmd.error(
ErrorKind::InvalidValue,
format!("Invalid compression algorithm '{}'. Supported algorithms: lz4, gzip, xz, zstd", compression_name),
)
.exit();
}
compression_type_opt.unwrap()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
Table,
Json,
Yaml,
}
impl FromStr for OutputFormat {
impl std::str::FromStr for ColumnType {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"table" => Ok(OutputFormat::Table),
"json" => Ok(OutputFormat::Json),
"yaml" => Ok(OutputFormat::Yaml),
_ => Err(anyhow::anyhow!("Invalid output format. Supported formats: table, json, yaml")),
fn from_str(s: &str) -> anyhow::Result<Self> {
let lower_s = s.to_lowercase();
if s.starts_with("meta:") {
Ok(ColumnType::Meta)
} else {
for variant in ColumnType::iter() {
if variant.to_string().to_lowercase() == lower_s {
return Ok(variant);
}
}
Err(anyhow::anyhow!("Invalid column type: {}", s))
}
}
}
pub fn get_output_format(args: &Args) -> OutputFormat {
args.options.output_format
.as_ref()
.and_then(|s| OutputFormat::from_str(s).ok())
.unwrap_or(OutputFormat::Table)
}
pub fn cmd_args_meta_plugin_types(cmd: &mut Command, args: &Args) -> Vec<MetaPluginType> {
/// Extracts configured meta plugin types from settings and command.
///
/// Handles comma-separated plugin names and validates against registered types.
///
/// # Arguments
///
/// * `cmd` - Mutable Clap command for error reporting.
/// * `settings` - Application settings with plugin config.
///
/// # Returns
///
/// `Vec<MetaPluginType>` - List of enabled plugin types.
///
/// # Panics
///
/// Exits via Clap error if unknown plugin type specified.
pub fn settings_meta_plugin_types(
cmd: &mut Command,
settings: &config::Settings,
) -> Vec<MetaPluginType> {
let mut meta_plugin_types = Vec::new();
// Handle comma-separated values in each meta_plugins argument
for meta_plugin_names_str in &args.item.meta_plugins {
for meta_plugin_names_str in &settings.meta_plugins_names() {
let meta_plugin_names: Vec<&str> = meta_plugin_names_str.split(',').collect();
for name in meta_plugin_names {
let trimmed_name = name.trim();
if trimmed_name.is_empty() {
continue;
}
// Try to find the MetaPluginType by meta name
let mut found = false;
for meta_plugin_type in MetaPluginType::iter() {
let mut meta_plugin = crate::meta_plugin::get_meta_plugin(meta_plugin_type.clone());
if meta_plugin.meta_name() == trimmed_name {
if let Ok(meta_plugin) =
crate::meta_plugin::get_meta_plugin(meta_plugin_type.clone(), None, None)
&& meta_plugin.meta_type().to_string() == trimmed_name
{
meta_plugin_types.push(meta_plugin_type);
found = true;
break;
}
}
if !found {
cmd.error(
ErrorKind::InvalidValue,
format!("Unknown meta plugin type: {}", trimmed_name),
format!("Unknown meta plugin type: {trimmed_name}"),
)
.exit();
}
@@ -225,3 +211,493 @@ pub fn cmd_args_meta_plugin_types(cmd: &mut Command, args: &Args) -> Vec<MetaPlu
meta_plugin_types
}
/// Determines compression type from settings and command arguments.
///
/// Validates the compression name and returns the corresponding enum variant.
///
/// # Arguments
///
/// * `cmd` - Mutable Clap command for error reporting.
/// * `settings` - Application settings.
///
/// # Returns
///
/// `CompressionType` - The resolved compression type.
///
/// # Panics
///
/// Exits via Clap error if invalid compression specified.
pub fn settings_compression_type(
cmd: &mut Command,
settings: &config::Settings,
) -> CompressionType {
let compression_name = settings
.compression()
.unwrap_or(CompressionType::LZ4.to_string());
let compression_type_opt = CompressionType::from_str(&compression_name);
if compression_type_opt.is_err() {
cmd.error(
ErrorKind::InvalidValue,
format!("Invalid compression algorithm '{compression_name}'. Supported algorithms: lz4, gzip, xz, zstd"),
)
.exit();
}
compression_type_opt.unwrap()
}
/// Parses output format from settings.
///
/// Defaults to `Table` if not specified or invalid. Uses case-insensitive string parsing.
///
/// # Arguments
///
/// * `settings` - Application settings with optional output_format field.
///
/// # Returns
///
/// `OutputFormat` - Parsed enum variant or Table as default.
///
/// # Examples
///
/// ```
/// # use keep::modes::common::{settings_output_format, OutputFormat};
/// // Example usage requires a Settings instance
/// // let format = settings_output_format(&settings);
/// ```
pub fn settings_output_format(settings: &config::Settings) -> OutputFormat {
settings
.output_format
.as_ref()
.and_then(|s| OutputFormat::from_str(s).ok())
.unwrap_or(OutputFormat::Table)
}
/// Trims trailing whitespace from each line in a multi-line string.
///
/// Useful for cleaning up table output before printing. Preserves newlines but removes spaces/tabs at line ends.
///
/// # Arguments
///
/// * `s` - Input string with potential trailing whitespace, e.g., "line1 \nline2 ".
///
/// # Returns
///
/// `String` - Cleaned string with trimmed lines, e.g., "line1\nline2".
///
/// # Examples
///
/// ```
/// # use keep::modes::common::trim_lines_end;
/// let cleaned = trim_lines_end("line1 \nline2 ");
/// assert_eq!(cleaned, "line1\nline2");
/// ```
pub fn trim_lines_end(s: &str) -> String {
s.lines()
.map(|line| line.trim_end())
.collect::<Vec<&str>>()
.join("\n")
}
/// Creates a new table with styling based on terminal detection.
///
/// Loads appropriate preset (UTF8 or ASCII) if styling is enabled.
///
/// # Arguments
///
/// * `use_styling` - If true, apply visual styling.
///
/// # Returns
///
/// `Table` - Configured table instance.
///
/// # Examples
///
/// ```
/// # use keep::modes::common::create_table;
/// let mut table = create_table(true);
/// table.add_row(vec!["Header1", "Header2"]);
/// ```
pub fn create_table(_use_styling: bool) -> Table {
create_table_with_config(&crate::config::TableConfig::default())
}
/// Creates a table configured from application table settings.
///
/// Applies style presets, modifiers, content arrangement, and truncation indicators.
///
/// # Arguments
///
/// * `table_config` - Table configuration from settings.
///
/// # Returns
///
/// `Table` - Fully configured table.
///
/// # Examples
///
/// ```
/// # use keep::modes::common::create_table_with_config;
/// # use keep::config::TableConfig;
/// let config = TableConfig::default();
/// let table = create_table_with_config(&config);
/// ```
pub fn create_table_with_config(table_config: &crate::config::TableConfig) -> Table {
let mut table = Table::new();
// Set content arrangement
match table_config.content_arrangement {
crate::config::ContentArrangement::Dynamic => {
table.set_content_arrangement(comfy_table::ContentArrangement::Dynamic)
}
crate::config::ContentArrangement::DynamicFullWidth => {
table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth)
}
crate::config::ContentArrangement::Disabled => {
table.set_content_arrangement(comfy_table::ContentArrangement::Disabled)
}
};
// Set style preset
match &table_config.style {
crate::config::TableStyle::Ascii => {
table.load_preset(comfy_table::presets::ASCII_FULL);
}
crate::config::TableStyle::Utf8 => {
table.load_preset(comfy_table::presets::UTF8_FULL);
}
crate::config::TableStyle::Utf8Full => {
table.load_preset(comfy_table::presets::UTF8_FULL);
}
crate::config::TableStyle::Nothing => {
table.load_preset(comfy_table::presets::NOTHING);
}
crate::config::TableStyle::Custom(preset) => {
// For custom presets, we'd need to parse the string
// This is a placeholder for custom preset handling
if preset == "ASCII_FULL" {
table.load_preset(comfy_table::presets::ASCII_FULL);
} else if preset == "UTF8_FULL" {
table.load_preset(comfy_table::presets::UTF8_FULL);
} else if preset == "NOTHING" {
table.load_preset(comfy_table::presets::NOTHING);
}
// Add more presets as needed
}
};
// Apply modifiers
for modifier in &table_config.modifiers {
match modifier.as_str() {
"UTF8_SOLID_INNER_BORDERS" => {
table.apply_modifier(comfy_table::modifiers::UTF8_SOLID_INNER_BORDERS);
}
"UTF8_ROUND_CORNERS" => {
table.apply_modifier(comfy_table::modifiers::UTF8_ROUND_CORNERS);
}
_ => {} // Ignore unknown modifiers
}
}
// Set truncation indicator if specified
if !table_config.truncation_indicator.is_empty() {
table.set_truncation_indicator(&table_config.truncation_indicator);
}
if !std::io::stdout().is_terminal() {
table.force_no_tty();
}
table
}
/// Display data for a single item's detail view (used by --info).
pub struct DisplayItemInfo {
pub id: i64,
pub timestamp: String,
pub path: String,
pub stream_size: String,
pub compression: String,
pub file_size: String,
pub tags: Vec<String>,
pub metadata: Vec<(String, String)>,
}
/// Renders item detail table. Shared by local and client info modes.
pub fn render_item_info_table(info: &DisplayItemInfo, table_config: &config::TableConfig) {
use comfy_table::{Attribute, Cell};
let mut table = create_table_with_config(table_config);
table.add_row(vec![
Cell::new("ID").add_attribute(Attribute::Bold),
Cell::new(info.id.to_string()),
]);
table.add_row(vec![
Cell::new("Time").add_attribute(Attribute::Bold),
Cell::new(&info.timestamp),
]);
table.add_row(vec![
Cell::new("Size").add_attribute(Attribute::Bold),
Cell::new(&info.stream_size),
]);
table.add_row(vec![
Cell::new("Compression").add_attribute(Attribute::Bold),
Cell::new(&info.compression),
]);
table.add_row(vec![
Cell::new("Tags").add_attribute(Attribute::Bold),
Cell::new(info.tags.join(" ")),
]);
for (key, value) in &info.metadata {
table.add_row(vec![
Cell::new(format!("Meta: {key}")).add_attribute(Attribute::Bold),
Cell::new(value),
]);
}
println!("{}", trim_lines_end(&table.trim_fmt()));
}
/// Renders list table with column format from config. Shared by local and client list modes.
pub fn render_list_table_with_format(
columns: &[config::ColumnConfig],
rows: &[Vec<String>],
table_config: &config::TableConfig,
) {
let mut table = create_table_with_config(table_config);
let header_cells: Vec<Cell> = columns
.iter()
.map(|col| Cell::new(&col.label).add_attribute(Attribute::Bold))
.collect();
table.set_header(header_cells);
for row in rows {
let cells: Vec<Cell> = row
.iter()
.enumerate()
.map(|(i, val)| {
let mut cell = Cell::new(val);
if let Some(col) = columns.get(i) {
if let Some(ref fg) = col.fg_color {
cell = apply_color(cell, fg, true);
}
if let Some(ref bg) = col.bg_color {
cell = apply_color(cell, bg, false);
}
for attr in &col.attributes {
cell = apply_table_attribute(cell, attr);
}
}
cell
})
.collect();
table.add_row(cells);
}
println!("{}", trim_lines_end(&table.trim_fmt()));
}
/// Applies config TableColor to a comfy-table Cell.
pub fn apply_color(mut cell: Cell, color: &config::TableColor, is_foreground: bool) -> Cell {
use comfy_table::Color;
let comfy_color = match color {
config::TableColor::Black => Color::Black,
config::TableColor::Red => Color::Red,
config::TableColor::Green => Color::Green,
config::TableColor::Yellow => Color::Yellow,
config::TableColor::Blue => Color::Blue,
config::TableColor::Magenta => Color::Magenta,
config::TableColor::Cyan => Color::Cyan,
config::TableColor::White => Color::White,
config::TableColor::Gray => Color::Grey,
config::TableColor::DarkRed => Color::DarkRed,
config::TableColor::DarkGreen => Color::DarkGreen,
config::TableColor::DarkYellow => Color::DarkYellow,
config::TableColor::DarkBlue => Color::DarkBlue,
config::TableColor::DarkMagenta => Color::DarkMagenta,
config::TableColor::DarkCyan => Color::DarkCyan,
config::TableColor::Rgb(r, g, b) => Color::Rgb {
r: *r,
g: *g,
b: *b,
},
};
if is_foreground {
cell = cell.fg(comfy_color);
} else {
cell = cell.bg(comfy_color);
}
cell
}
/// Ensures tags has at least one entry, adding "none" if empty.
pub fn ensure_default_tag(tags: &mut Vec<String>) {
if tags.is_empty() {
tags.push("none".to_string());
}
}
/// Prints a serializable value in JSON or YAML format based on output format.
///
/// Only handles Json and Yaml variants; Table should be handled separately.
pub fn print_serialized<T: serde::Serialize>(
value: &T,
format: &OutputFormat,
) -> anyhow::Result<()> {
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(value)?),
OutputFormat::Yaml => println!("{}", serde_yaml::to_string(value)?),
OutputFormat::Table => unreachable!(),
}
Ok(())
}
/// Applies config TableAttribute to a comfy-table Cell.
pub fn apply_table_attribute(mut cell: Cell, attribute: &config::TableAttribute) -> Cell {
match attribute {
config::TableAttribute::Bold => cell = cell.add_attribute(Attribute::Bold),
config::TableAttribute::Dim => cell = cell.add_attribute(Attribute::Dim),
config::TableAttribute::Italic => cell = cell.add_attribute(Attribute::Italic),
config::TableAttribute::Underlined => cell = cell.add_attribute(Attribute::Underlined),
config::TableAttribute::SlowBlink => cell = cell.add_attribute(Attribute::SlowBlink),
config::TableAttribute::RapidBlink => cell = cell.add_attribute(Attribute::RapidBlink),
config::TableAttribute::Reverse => cell = cell.add_attribute(Attribute::Reverse),
config::TableAttribute::Hidden => cell = cell.add_attribute(Attribute::Hidden),
config::TableAttribute::CrossedOut => cell = cell.add_attribute(Attribute::CrossedOut),
}
cell
}
/// Builds a table showing data and database path information.
pub fn build_path_table(path_info: &PathInfo, table_config: &config::TableConfig) -> Table {
let mut path_table = create_table_with_config(table_config);
path_table.set_header(vec![
Cell::new("Type").add_attribute(Attribute::Bold),
Cell::new("Path").add_attribute(Attribute::Bold),
]);
path_table.add_row(vec!["Data", &path_info.data]);
path_table.add_row(vec!["Database", &path_info.database]);
path_table
}
/// Sanitize tags for use in filenames.
///
/// Replaces non-alphanumeric characters with underscores and joins with `_`.
/// Empty tags are filtered out to avoid double underscores.
pub fn sanitize_tags(tags: &[String]) -> String {
tags.iter()
.filter(|t| !t.is_empty())
.map(|t| {
t.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
})
.collect::<Vec<_>>()
.join("_")
}
/// Metadata structure for export to YAML. Shared by local and client export modes.
#[derive(Debug, Serialize)]
pub struct ExportMeta {
pub ts: DateTime<Utc>,
pub compression: String,
pub uncompressed_size: Option<i64>,
pub tags: Vec<String>,
pub metadata: HashMap<String, String>,
}
/// Metadata structure for import from YAML. Shared by local and client import modes.
#[derive(Debug, Deserialize)]
pub struct ImportMeta {
pub ts: DateTime<Utc>,
pub compression: String,
#[serde(default, alias = "size")]
pub uncompressed_size: Option<i64>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub metadata: HashMap<String, String>,
}
/// Resolve a single item ID from explicit IDs, tags, or latest item.
///
/// Returns the first ID if provided, the newest item matching tags,
/// or the newest item overall if neither is specified.
#[cfg(feature = "client")]
pub fn resolve_item_id(
client: &crate::client::KeepClient,
ids: &[i64],
tags: &[String],
) -> Result<i64> {
if !ids.is_empty() {
Ok(ids[0])
} else if !tags.is_empty() {
let items = client.list_items(&[], tags, "newest", 0, 1, &HashMap::new())?;
if items.is_empty() {
return Err(anyhow!("No items found matching tags: {:?}", tags));
}
Ok(items[0].id)
} else {
let items = client.list_items(&[], &[], "newest", 0, 1, &HashMap::new())?;
if items.is_empty() {
return Err(anyhow!("No items found"));
}
Ok(items[0].id)
}
}
/// Resolve item IDs from explicit IDs or tags (multi-item variant).
#[cfg(feature = "client")]
pub fn resolve_item_ids(
client: &crate::client::KeepClient,
ids: &[i64],
tags: &[String],
) -> Result<Vec<i64>> {
if !ids.is_empty() {
Ok(ids.to_vec())
} else if !tags.is_empty() {
let items = client.list_items(&[], tags, "newest", 0, 0, &HashMap::new())?;
if items.is_empty() {
return Err(anyhow!("No items found matching tags: {:?}", tags));
}
Ok(items.into_iter().map(|i| i.id).collect())
} else {
let items = client.list_items(&[], &[], "newest", 0, 1, &HashMap::new())?;
if items.is_empty() {
return Err(anyhow!("No items found"));
}
Ok(vec![items[0].id])
}
}
/// Check if binary content should be blocked from TTY output.
///
/// Uses metadata `text` field as fast path, then falls back to byte sampling.
/// Returns Err if content is binary and should not be displayed.
pub fn check_binary_tty(
metadata: &HashMap<String, String>,
data_sample: &[u8],
force: bool,
) -> Result<()> {
if force || !std::io::stdout().is_terminal() {
return Ok(());
}
if crate::common::is_binary::is_content_binary_from_metadata(metadata, data_sample) {
return Err(anyhow!(
"Refusing to output binary data to TTY, use --force to override"
));
}
Ok(())
}

View File

@@ -1,52 +1,76 @@
use anyhow::{Context, Result, anyhow};
use std::fs;
use anyhow::Result;
use std::path::PathBuf;
use crate::db;
use crate::config;
use crate::services::error::CoreError;
use crate::services::item_service::ItemService;
use clap::Command;
use clap::error::ErrorKind;
use log::{debug, warn};
use log::warn;
use rusqlite::Connection;
/// Handles the delete mode: removes items by ID from the database and storage.
///
/// This function processes a list of item IDs, attempting to delete each from
/// both the database and the underlying file storage. It skips items that are
/// not found and logs warnings for them. Validation of arguments (e.g., ensuring
/// IDs are provided and tags are empty) is handled at the clap parsing level.
///
/// # Arguments
///
/// * `_cmd` - Clap command for error handling (unused).
/// * `_settings` - Global settings (unused).
/// * `_config` - Configuration settings (unused).
/// * `ids` - List of item IDs to delete.
/// * `_tags` - Tags (unused, as delete only supports IDs).
/// * `conn` - Database connection.
/// * `data_path` - Path to data directory for storage cleanup.
///
/// # Returns
///
/// `Result<()>` on success, or an error if deletion fails for any item.
///
/// # Errors
///
/// Returns an `anyhow::Error` if a deletion operation fails due to database
/// or I/O issues (excluding `ItemNotFound`, which is handled gracefully).
///
/// # Examples
///
/// ```ignore
/// // This would be called from main after parsing args
/// mode_delete(&mut cmd, &settings, &config, &mut vec![1, 2], &mut vec![], &mut conn, data_path)?;
/// ```
///
/// # Panics
///
/// None.
pub fn mode_delete(
cmd: &mut Command,
_args: &crate::Args,
ids: &mut Vec<i64>,
tags: &mut Vec<String>,
_cmd: &mut Command,
_settings: &config::Settings,
_config: &config::Settings,
ids: &mut [i64],
_tags: &mut [String],
conn: &mut Connection,
data_path: PathBuf,
) -> Result<()> {
if ids.is_empty() {
cmd.error(
ErrorKind::InvalidValue,
"No ID given, you must supply atleast one ID when using --delete",
)
.exit();
} else if !tags.is_empty() {
cmd.error(
ErrorKind::InvalidValue,
"Tags given but not supported, you must supply atleast one ID when using --delete",
)
.exit();
}
// Validation is now handled at the argument parsing level
// So we can assume ids is not empty and tags is empty
let item_service = ItemService::new(data_path);
for item_id in ids.iter() {
if let Some(item) = db::get_item(conn, *item_id)? {
debug!("MAIN: Found item {:?}", item);
db::delete_item(conn, item)?;
// Validate that item ID is positive to prevent path traversal issues
if *item_id <= 0 {
return Err(anyhow!("Invalid item ID: {}", item_id));
}
let mut item_path = data_path.clone();
item_path.push(item_id.to_string());
fs::remove_file(&item_path)
.context(anyhow!("Unable to remove item file {:?}", item_path))?;
} else {
warn!("Unable to find item {item_id} in database");
match item_service.delete_item(conn, *item_id) {
Ok(_) => {}
Err(e) => match e {
CoreError::ItemNotFound(_) => {
warn!("Unable to find item {item_id} in database");
}
_ => {
return Err(
anyhow::Error::from(e).context(format!("Failed to delete item {item_id}"))
);
}
},
}
}

View File

@@ -1,405 +1,203 @@
use anyhow::{anyhow, Result};
/// Diff mode implementation.
///
/// This module provides functionality for comparing two items and displaying their
/// differences using external diff tools. Decompressed content is streamed to diff
/// via pipes and /dev/fd file descriptors — no temporary files are created.
use crate::config;
use crate::services::compression_service::CompressionService;
use crate::services::item_service::ItemService;
use anyhow::{Context, Result, anyhow};
use clap::Command;
use command_fds::{CommandFdExt, FdMapping};
use log::debug;
use nix::fcntl::OFlag;
use nix::unistd::pipe2;
use std::io::Read;
use std::os::fd::FromRawFd;
use std::str::FromStr;
use std::os::unix::io::{AsRawFd, OwnedFd};
fn validate_diff_args(cmd: &mut Command, ids: &Vec<i64>, tags: &Vec<String>) {
fn validate_diff_args(_cmd: &mut Command, ids: &[i64], tags: &[String]) -> anyhow::Result<()> {
if !tags.is_empty() {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"Tags are not supported with --diff. Please provide exactly two IDs.",
)
.exit();
return Err(anyhow::anyhow!(
"Tags are not supported with --diff. Please provide exactly two IDs."
));
}
if ids.len() != 2 {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"You must supply exactly two IDs when using --diff.",
)
.exit();
return Err(anyhow::anyhow!(
"You must supply exactly two IDs when using --diff."
));
}
Ok(())
}
/// Fetches and validates items from the database for diff operation.
fn fetch_and_validate_items(
conn: &mut rusqlite::Connection,
ids: &Vec<i64>,
) -> Result<(crate::db::Item, crate::db::Item), anyhow::Error> {
// Fetch items, ensuring they exist.
let item_a = crate::db::get_item(conn, ids[0])?
.ok_or_else(|| anyhow::anyhow!("Unable to find first item (ID: {}) in database", ids[0]))?;
let item_b = crate::db::get_item(conn, ids[1])?
.ok_or_else(|| anyhow::anyhow!("Unable to find second item (ID: {}) in database", ids[1]))?;
ids: &[i64],
item_service: &ItemService,
) -> Result<(
crate::services::types::ItemWithMeta,
crate::services::types::ItemWithMeta,
)> {
let item_a = item_service
.get_item(conn, ids[0])
.with_context(|| format!("Unable to find first item (ID: {}) in database", ids[0]))?;
let item_b = item_service
.get_item(conn, ids[1])
.with_context(|| format!("Unable to find second item (ID: {}) in database", ids[1]))?;
log::debug!("MAIN: Found item A {:?}", item_a);
log::debug!("MAIN: Found item B {:?}", item_b);
let item_a_id = item_a.id.ok_or_else(|| anyhow!("Item A missing ID"))?;
let item_b_id = item_b.id.ok_or_else(|| anyhow!("Item B missing ID"))?;
// Validate that item IDs are positive to prevent path traversal issues
if item_a_id <= 0 || item_b_id <= 0 {
return Err(anyhow::anyhow!("Invalid item ID: {} or {}", item_a_id, item_b_id));
}
debug!("DIFF: Found item A {:?}", item_a.item);
debug!("DIFF: Found item B {:?}", item_b.item);
Ok((item_a, item_b))
}
fn get_item_tags(conn: &mut rusqlite::Connection, item: &crate::db::Item) -> Result<Vec<String>, anyhow::Error> {
let tags: Vec<String> = crate::db::get_item_tags(conn, item)?
.into_iter()
.map(|x| x.name)
pub fn mode_diff(
cmd: &mut Command,
args: &crate::args::Args,
conn: &mut rusqlite::Connection,
) -> anyhow::Result<()> {
let ids: Vec<i64> = args
.ids_or_tags
.iter()
.filter_map(|x| {
if let crate::args::NumberOrString::Number(n) = x {
Some(*n)
} else {
None
}
})
.collect();
Ok(tags)
let tags: Vec<String> = args
.ids_or_tags
.iter()
.filter_map(|x| {
if let crate::args::NumberOrString::Str(s) = x {
Some(s.clone())
} else {
None
}
})
.collect();
validate_diff_args(cmd, &ids, &tags)?;
let settings = config::Settings::new(args, config::Settings::default_dir()?)?;
let item_service = ItemService::new(settings.dir.clone());
let (item_a, item_b) = fetch_and_validate_items(conn, &ids, &item_service)?;
run_external_diff(&item_service, &item_a, &item_b)
}
fn setup_diff_paths_and_compression(
data_path: &std::path::PathBuf,
item_a: &crate::db::Item,
item_b: &crate::db::Item,
) -> Result<(std::path::PathBuf, crate::compression_engine::CompressionType, std::path::PathBuf, crate::compression_engine::CompressionType), anyhow::Error> {
let item_a_id = item_a.id.ok_or_else(|| anyhow::anyhow!("Item A missing ID"))?;
let item_b_id = item_b.id.ok_or_else(|| anyhow::anyhow!("Item B missing ID"))?;
let mut item_path_a = data_path.clone();
item_path_a.push(item_a_id.to_string());
let compression_type_a = crate::compression_engine::CompressionType::from_str(&item_a.compression)?;
log::debug!("MAIN: Item A has compression type {:?}", compression_type_a);
let mut item_path_b = data_path.clone();
item_path_b.push(item_b_id.to_string());
let compression_type_b = crate::compression_engine::CompressionType::from_str(&item_b.compression)?;
log::debug!("MAIN: Item B has compression type {:?}", compression_type_b);
Ok((item_path_a, compression_type_a, item_path_b, compression_type_b))
/// Creates a pipe with CLOEXEC set atomically, returns (read_fd, write_fd).
fn create_pipe() -> Result<(OwnedFd, OwnedFd)> {
pipe2(OFlag::O_CLOEXEC).context("Failed to create pipe")
}
fn setup_diff_pipes() -> Result<((libc::c_int, libc::c_int), (libc::c_int, libc::c_int)), anyhow::Error> {
use nix::unistd::pipe;
use nix::Error as NixError;
// Create pipes for diff's input
let (fd_a_read, fd_a_write) =
pipe().map_err(|e: NixError| anyhow::anyhow!("Failed to create pipe A: {}", e))?;
let (fd_b_read, fd_b_write) =
pipe().map_err(|e: NixError| anyhow::anyhow!("Failed to create pipe B: {}", e))?;
Ok(((fd_a_read, fd_a_write), (fd_b_read, fd_b_write)))
}
fn setup_fd_guards(fd_a_read: libc::c_int, fd_b_read: libc::c_int) -> (FdGuard, FdGuard) {
// Wrap file descriptors in RAII guards
let fd_a_read_guard = FdGuard::new(fd_a_read);
let fd_b_read_guard = FdGuard::new(fd_b_read);
(fd_a_read_guard, fd_b_read_guard)
}
fn set_fd_cloexec(fd_a_write: libc::c_int, fd_b_write: libc::c_int) -> Result<(), anyhow::Error> {
use nix::fcntl::{fcntl, FcntlArg, FdFlag};
// Set FD_CLOEXEC on write ends
fcntl(
fd_a_write,
FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC),
)
.map_err(|e| anyhow::anyhow!("Failed to set FD_CLOEXEC on fd_a_write: {}", e))?;
fcntl(
fd_b_write,
FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC),
)
.map_err(|e| anyhow::anyhow!("Failed to set FD_CLOEXEC on fd_b_write: {}", e))?;
Ok(())
}
fn spawn_diff_process(
item_a_id: i64,
item_a_tags: Vec<String>,
item_b_id: i64,
item_b_tags: Vec<String>,
fd_a_read: libc::c_int,
fd_b_read: libc::c_int,
) -> Result<std::process::Child, anyhow::Error> {
log::debug!("MAIN: Creating child process for diff");
let mut diff_command = std::process::Command::new("diff");
diff_command
.arg("-u")
.arg("--label")
.arg(format!(
"Keep item A: {} {}",
item_a_id,
item_a_tags.join(" ")
))
.arg(format!("/dev/fd/{}", fd_a_read))
.arg("--label")
.arg(format!(
"Keep item B: {} {}",
item_b_id,
item_b_tags.join(" ")
))
.arg(format!("/dev/fd/{}", fd_b_read))
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let child_process = diff_command
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to execute diff command: {}", e))?;
Ok(child_process)
}
// RAII guard for file descriptors to ensure they're closed
struct FdGuard {
fd: libc::c_int,
}
impl FdGuard {
fn new(fd: libc::c_int) -> Self {
Self { fd }
}
}
impl Drop for FdGuard {
fn drop(&mut self) {
let _ = nix::unistd::close(self.fd);
}
}
// Create a function to write item data to a pipe
fn write_item_to_pipe(
item_path: std::path::PathBuf,
compression_type: crate::compression_engine::CompressionType,
pipe_writer_raw: std::fs::File,
) -> Result<(), anyhow::Error> {
use std::io::BufWriter;
let mut buffered_pipe_writer = BufWriter::new(pipe_writer_raw);
let engine =
crate::compression_engine::get_compression_engine(compression_type).expect("Unable to get compression engine");
log::debug!("THREAD: Sending item to diff");
engine
.copy(item_path, &mut buffered_pipe_writer)
.map_err(|e| anyhow::anyhow!("Failed to copy/compress item: {}", e))?;
log::debug!("THREAD: Done sending item to diff");
Ok(())
}
// Function to spawn a writer thread for an item
/// Streams decompressed item content through a pipe fd.
///
/// Returns a JoinHandle for the writer thread. The thread writes decompressed
/// data to write_fd and closes it when done (causing EOF for the reader).
fn spawn_writer_thread(
item_path: std::path::PathBuf,
compression_type: crate::compression_engine::CompressionType,
fd_write: libc::c_int,
) -> std::thread::JoinHandle<Result<(), anyhow::Error>> {
let pipe_writer_raw = unsafe { std::fs::File::from_raw_fd(fd_write) };
std::thread::spawn(move || {
write_item_to_pipe(item_path, compression_type, pipe_writer_raw)
item_service: &ItemService,
item: &crate::services::types::ItemWithMeta,
write_fd: OwnedFd,
) -> std::thread::JoinHandle<Result<()>> {
let data_path = item_service.get_data_path().clone();
let id = match item.item.id {
Some(id) => id,
None => return std::thread::spawn(|| Err(anyhow!("item missing ID"))),
};
let compression = item.item.compression.clone();
let mut item_path = data_path;
item_path.push(id.to_string());
std::thread::spawn(move || -> Result<()> {
let compression_service = CompressionService::new();
let mut reader = compression_service
.stream_item_content(item_path, &compression)
.map_err(|e| anyhow::anyhow!("Failed to stream item {id}: {e}"))?;
// Convert OwnedFd to File — safe, takes ownership, closes on drop
let mut writer = std::fs::File::from(write_fd);
crate::common::stream_copy(&mut reader, |chunk| {
use std::io::Write;
writer.write_all(chunk)
})
.map_err(|e| anyhow::anyhow!("Error reading item {id}: {e}"))?;
// writer dropped here, closing write_fd → diff sees EOF
Ok(())
})
}
fn execute_diff_command(
child_process: &mut std::process::Child,
) -> Result<(Vec<u8>, Vec<u8>), anyhow::Error> {
let mut child_stdout_pipe = child_process
.stdout
.take()
.expect("BUG: Failed to capture diff stdout pipe");
let mut child_stderr_pipe = child_process
.stderr
.take()
.expect("BUG: Failed to capture diff stderr pipe");
/// Runs external diff command, streaming decompressed content via /dev/fd pipes.
///
/// Creates two pipes, spawns writer threads to decompress each item into its pipe,
/// and runs `diff -u /dev/fd/N /dev/fd/M` where N and M are the pipe read fds.
/// The `command-fds` crate handles CLOEXEC clearing safely — no unsafe needed.
fn run_external_diff(
item_service: &ItemService,
item_a: &crate::services::types::ItemWithMeta,
item_b: &crate::services::types::ItemWithMeta,
) -> Result<()> {
if which::which_global("diff").is_err() {
return Err(anyhow::anyhow!(
"diff command not found. Please install diffutils."
));
}
log::debug!("MAIN: Creating threads for diff I/O");
let (read_fd_a, write_fd_a) = create_pipe()?;
let (read_fd_b, write_fd_b) = create_pipe()?;
// Thread to read diff's standard output
let stdout_reader_thread = std::thread::spawn(move || {
let mut output_buffer = Vec::new();
log::debug!("STDOUT_READER: Reading diff stdout");
// child_stdout_pipe is a ChildStdout, which implements std::io::Read
child_stdout_pipe
.read_to_end(&mut output_buffer)
.map_err(|e| anyhow::anyhow!("Failed to read diff stdout: {}", e))
.map(|_| output_buffer) // Return the Vec<u8> on success
});
// Spawn writer threads — they take ownership of write fds and close them on exit
let writer_a = spawn_writer_thread(item_service, item_a, write_fd_a);
let writer_b = spawn_writer_thread(item_service, item_b, write_fd_b);
// Thread to read diff's standard error
let stderr_reader_thread = std::thread::spawn(move || {
let mut error_buffer = Vec::new();
log::debug!("STDERR_READER: Reading diff stderr");
child_stderr_pipe
.read_to_end(&mut error_buffer)
.map_err(|e| anyhow::anyhow!("Failed to read diff stderr: {}", e))
.map(|_| error_buffer)
});
// Get fd numbers for /dev/fd paths (borrows, does not consume)
let raw_read_a = read_fd_a.as_raw_fd();
let raw_read_b = read_fd_b.as_raw_fd();
// Retrieve the captured output from the reader threads.
let stdout_capture_result = stdout_reader_thread
debug!("DIFF: pipe fds: a(r={raw_read_a}) b(r={raw_read_b})");
// Spawn diff with /dev/fd/N paths. command-fds handles CLOEXEC clearing
// and fd inheritance safely — the fds are released from OwnedFd to the
// child process. If spawn fails, the OwnedFd values in FdMapping are
// dropped and the fds are properly closed.
let mut command = std::process::Command::new("diff");
command
.arg("-u")
.arg(format!("/dev/fd/{raw_read_a}"))
.arg(format!("/dev/fd/{raw_read_b}"))
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.stdin(std::process::Stdio::null())
.fd_mappings(vec![
FdMapping {
parent_fd: read_fd_a,
child_fd: raw_read_a,
},
FdMapping {
parent_fd: read_fd_b,
child_fd: raw_read_b,
},
])
.map_err(|e| anyhow::anyhow!("FD mapping collision: {e}"))?;
let mut child = command.spawn().context("Failed to spawn diff command")?;
let status = child.wait().context("Failed to wait for diff command")?;
// Join writer threads and propagate errors
writer_a
.join()
.map_err(|panic_payload| {
anyhow::anyhow!("Stdout reader thread panicked: {:?}", panic_payload)
})?
.map_err(|e| anyhow::anyhow!("Failed to read diff stdout: {}", e))?;
let stderr_capture_result = stderr_reader_thread
.map_err(|e| anyhow::anyhow!("Writer A panicked: {e:?}"))??;
writer_b
.join()
.map_err(|panic_payload| {
anyhow::anyhow!("Stderr reader thread panicked: {:?}", panic_payload)
})?
.map_err(|e| anyhow::anyhow!("Failed to read diff stderr: {}", e))?;
.map_err(|e| anyhow::anyhow!("Writer B panicked: {e:?}"))??;
Ok((stdout_capture_result, stderr_capture_result))
}
fn handle_diff_output(
diff_status: std::process::ExitStatus,
stdout_capture_result: Vec<u8>,
stderr_capture_result: Vec<u8>,
) -> Result<(), anyhow::Error> {
// Handle diff's exit status and output
match diff_status.code() {
Some(0) => {
// Exit code 0: No differences
log::debug!("MAIN: Diff successful, no differences found.");
// Typically, diff -u doesn't print to stdout if no differences.
// But if it did, it would be shown here.
if !stdout_capture_result.is_empty() {
println!("{}", String::from_utf8_lossy(&stdout_capture_result));
}
}
Some(1) => {
// Exit code 1: Differences found
log::debug!("MAIN: Diff successful, differences found.");
println!("{}", String::from_utf8_lossy(&stdout_capture_result));
}
Some(error_code) => {
// Exit code > 1: Error in diff utility
eprintln!("Diff command failed with exit code: {}", error_code);
if !stdout_capture_result.is_empty() {
eprintln!(
"Diff stdout before error:\n{}",
String::from_utf8_lossy(&stdout_capture_result)
);
}
if !stderr_capture_result.is_empty() {
eprintln!(
"Diff stderr:\n{}",
String::from_utf8_lossy(&stderr_capture_result)
);
}
return Err(anyhow::anyhow!(
"Diff command reported an error (exit code {})",
error_code
));
}
None => {
// Process terminated by a signal
eprintln!("Diff command terminated by signal.");
if !stderr_capture_result.is_empty() {
eprintln!(
"Diff stderr before signal termination:\n{}",
String::from_utf8_lossy(&stderr_capture_result)
);
}
return Err(anyhow::anyhow!("Diff command terminated by signal"));
}
// diff returns 0 if identical, 1 if different, 2 on error
if status.code() == Some(2) {
Err(anyhow::anyhow!("diff command failed with an error"))
} else {
Ok(())
}
Ok(())
}
pub fn mode_diff(
cmd: &mut Command,
_args: &crate::Args,
ids: &mut Vec<i64>,
tags: &mut Vec<String>,
conn: &mut rusqlite::Connection,
data_path: std::path::PathBuf,
) -> Result<(), anyhow::Error> {
validate_diff_args(cmd, ids, tags);
let (item_a, item_b) = fetch_and_validate_items(conn, ids)?;
let item_a_tags = get_item_tags(conn, &item_a)?;
let item_b_tags = get_item_tags(conn, &item_b)?;
let (item_path_a, compression_type_a, item_path_b, compression_type_b) =
setup_diff_paths_and_compression(&data_path, &item_a, &item_b)?;
let ((fd_a_read, fd_a_write), (fd_b_read, fd_b_write)) = setup_diff_pipes()?;
let (_fd_a_read_guard, _fd_b_read_guard) = setup_fd_guards(fd_a_read, fd_b_read);
set_fd_cloexec(fd_a_write, fd_b_write)?;
let item_a_id = item_a.id.ok_or_else(|| anyhow::anyhow!("Item A missing ID"))?;
let item_b_id = item_b.id.ok_or_else(|| anyhow::anyhow!("Item B missing ID"))?;
let mut child_process = spawn_diff_process(
item_a_id,
item_a_tags,
item_b_id,
item_b_tags,
fd_a_read,
fd_b_read,
)?;
// Close read ends in parent process - they're now guarded by FdGuard
drop(_fd_a_read_guard);
drop(_fd_b_read_guard);
// Spawn writer threads for both items
let writer_thread_a =
spawn_writer_thread(item_path_a.clone(), compression_type_a.clone(), fd_a_write);
let writer_thread_b =
spawn_writer_thread(item_path_b.clone(), compression_type_b.clone(), fd_b_write);
// Wait for writer threads to complete (meaning all input has been sent to diff)
log::debug!("MAIN: Waiting on writer thread for item A");
match writer_thread_a.join() {
Ok(Ok(())) => {
log::debug!("MAIN: Writer thread for item A completed successfully.");
}
Ok(Err(e)) => {
return Err(anyhow::anyhow!("Writer thread for item A failed: {}", e));
}
Err(panic_payload) => {
return Err(anyhow::anyhow!(
"Writer thread for item A (ID: {}) panicked: {:?}",
ids[0],
panic_payload
));
}
}
log::debug!("MAIN: Waiting on writer thread for item B");
match writer_thread_b.join() {
Ok(Ok(())) => {
log::debug!("MAIN: Writer thread for item B completed successfully.");
}
Ok(Err(e)) => {
return Err(anyhow::anyhow!("Writer thread for item B failed: {}", e));
}
Err(panic_payload) => {
return Err(anyhow::anyhow!(
"Writer thread for item B (ID: {}) panicked: {:?}",
ids[1],
panic_payload
));
}
}
log::debug!("MAIN: Done waiting on input-writer threads.");
// Now that all input has been sent and input pipes will be closed by threads exiting,
// wait for the diff child process to terminate.
log::debug!("MAIN: Waiting for diff child process to finish...");
let diff_status = child_process
.wait()
.map_err(|e| anyhow::anyhow!("Failed to wait on diff command: {}", e))?;
log::debug!(
"MAIN: Diff child process finished with status: {}",
diff_status
);
let (stdout_capture_result, stderr_capture_result) = execute_diff_command(&mut child_process)?;
handle_diff_output(diff_status, stdout_capture_result, stderr_capture_result)?;
Ok(())
}

145
src/modes/export.rs Normal file
View File

@@ -0,0 +1,145 @@
use anyhow::{Context, Result, anyhow};
use chrono::Utc;
use clap::Command;
use log::debug;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use crate::common::sanitize_ts_string;
use crate::config;
use crate::export_tar;
use crate::filter_plugin::FilterChain;
use crate::modes::common::sanitize_tags;
use crate::services::item_service::ItemService;
use crate::services::types::ItemWithMeta;
/// Export items to a `.keep.tar` archive.
///
/// Requires either IDs or tags (mutually exclusive). If IDs are given,
/// ALL must exist. Archives contain per-item data and metadata files.
pub fn mode_export(
cmd: &mut Command,
settings: &config::Settings,
ids: &[i64],
tags: &[String],
conn: &mut rusqlite::Connection,
data_path: PathBuf,
filter_chain: Option<FilterChain>,
) -> Result<()> {
// Validate: IDs XOR tags
if !ids.is_empty() && !tags.is_empty() {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"Cannot use both IDs and tags with --export",
)
.exit();
}
if ids.is_empty() && tags.is_empty() {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"Must provide either IDs or tags with --export",
)
.exit();
}
let item_service = ItemService::new(data_path.clone());
let meta_filter = settings.meta_filter();
// Resolve items
let items: Vec<ItemWithMeta> = if !ids.is_empty() {
// Fetch each ID individually; ALL must exist
let mut result = Vec::new();
for &id in ids {
match item_service.get_item(conn, id) {
Ok(item) => result.push(item),
Err(_) => {
cmd.error(
clap::error::ErrorKind::InvalidValue,
format!("Item {id} not found"),
)
.exit();
}
}
}
result
} else {
// Search by tags
item_service
.list_items(conn, tags, &meta_filter)
.map_err(|e| anyhow!("Unable to find matching items: {}", e))?
};
if items.is_empty() {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"No items found matching the given criteria",
)
.exit();
}
// Validate: --export-filename-format doesn't use per-item vars with multiple items
if items.len() > 1 {
let fmt = &settings.export_filename_format;
if fmt.contains("{id}") || fmt.contains("{tags}") || fmt.contains("{compression}") {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"Cannot use {id}, {tags}, or {compression} in --export-filename-format when exporting multiple items",
)
.exit();
}
}
// Compute export name
let dir_name = export_tar::export_name(&settings.export_name, &items);
// Compute tar filename from format template
let now = Utc::now();
let ts_str = sanitize_ts_string(&now.format("%Y-%m-%dT%H:%M:%SZ").to_string());
let mut vars = HashMap::new();
vars.insert("name".to_string(), dir_name.clone());
vars.insert("ts".to_string(), ts_str.clone());
// For single-item exports, also provide per-item vars
if items.len() == 1 {
let item = &items[0];
let item_id = item.item.id.context("Item missing ID")?;
let item_tags = item.tag_names();
vars.insert("id".to_string(), item_id.to_string());
vars.insert("tags".to_string(), sanitize_tags(&item_tags));
vars.insert("compression".to_string(), item.item.compression.clone());
}
let basename = strfmt::strfmt(&settings.export_filename_format, &vars).map_err(|e| {
anyhow!(
"Invalid export filename format '{}': {}",
settings.export_filename_format,
e
)
})?;
let tar_filename = format!("{basename}.keep.tar");
// Write the tar archive
let tar_file = fs::File::create(&tar_filename)
.with_context(|| format!("Cannot create tar file: {tar_filename}"))?;
export_tar::write_export_tar(
tar_file,
&dir_name,
&items,
&data_path,
filter_chain.as_ref(),
&item_service,
conn,
)?;
if !settings.quiet {
eprintln!("{tar_filename}");
}
debug!("EXPORT: Wrote {} items to {tar_filename}", items.len());
Ok(())
}

View File

@@ -0,0 +1,264 @@
use anyhow::Result;
use clap::Command;
use std::collections::HashMap;
use strum::IntoEnumIterator;
use crate::common::schema::{gather_filter_plugin_schemas, gather_meta_plugin_schemas};
use crate::compression_engine::CompressionType;
use crate::config;
/// Generates and prints a default commented YAML configuration template.
///
/// Discovers all registered meta plugins, filter plugins, and compression engines
/// at runtime via the plugin schema system. Outputs a commented YAML template
/// with all available plugins and their default options/outputs.
///
/// # Arguments
///
/// * `_cmd` - Unused Clap command reference.
/// * `_settings` - Unused settings reference.
///
/// # Returns
///
/// `Ok(())` on success.
pub fn mode_generate_config(_cmd: &mut Command, _settings: &crate::config::Settings) -> Result<()> {
let meta_schemas = gather_meta_plugin_schemas();
let filter_schemas = gather_filter_plugin_schemas();
// Build list_format defaults matching config.rs
let list_format = default_list_format();
// Build meta_plugins with env as the default (active), rest commented
let meta_plugins = build_meta_plugins_section(&meta_schemas);
// Build the full YAML
let mut lines = Vec::with_capacity(128);
lines.push("# Keep configuration file".to_string());
lines.push("# Uncomment and modify the settings you need.".to_string());
lines.push(String::new());
// Core settings
lines.push("# Data directory for storing items".to_string());
lines.push("dir: ~/.local/share/keep".to_string());
lines.push(String::new());
// List format
lines.push("# Column configuration for --list output".to_string());
lines.push("list_format:".to_string());
for col in &list_format {
lines.push(format!(" - name: {}", col.name));
lines.push(format!(" label: {}", col.label));
lines.push(format!(" align: {}", col.align));
}
lines.push(String::new());
// Table config
lines.push("# Table display configuration".to_string());
lines.push("#table_config:".to_string());
lines.push("# style: nothing".to_string());
lines.push("# modifiers: []".to_string());
lines.push("# content_arrangement: dynamic".to_string());
lines.push("# truncination_indicator: \"\"".to_string());
lines.push(String::new());
// Other settings
lines.push("human_readable: false".to_string());
lines.push("output_format: table".to_string());
lines.push("quiet: false".to_string());
lines.push("force: false".to_string());
lines.push(String::new());
// Server config
lines.push("# Server configuration (only used with --server)".to_string());
lines.push("server:".to_string());
lines.push(" address: 127.0.0.1".to_string());
lines.push(" port: 8080".to_string());
lines.push("# username: keep".to_string());
lines.push("# password: null".to_string());
lines.push("# password_file: null".to_string());
lines.push("# password_hash: null".to_string());
lines.push("# jwt_secret: null".to_string());
lines.push("# jwt_secret_file: null".to_string());
lines.push("# cert_file: null".to_string());
lines.push("# key_file: null".to_string());
lines.push("# cors_origin: null".to_string());
lines.push(String::new());
// Compression plugin
lines.push("# Compression plugin to use".to_string());
lines.push("#compression_plugin:".to_string());
let mut comp_types: Vec<String> = CompressionType::iter().map(|ct| ct.to_string()).collect();
comp_types.sort();
for ct in &comp_types {
lines.push(format!("# name: {ct} # {}", compression_description(ct)));
}
lines.push(String::new());
// Meta plugins
lines.push("# Meta plugins to run when saving items".to_string());
lines.push("meta_plugins:".to_string());
for line in &meta_plugins {
lines.push(line.clone());
}
lines.push(String::new());
// Filter plugins reference
if !filter_schemas.is_empty() {
lines.push("# Available filter plugins (use with --filter)".to_string());
for schema in &filter_schemas {
lines.push(format!("# {}", schema.name));
if !schema.description.is_empty() {
lines.push(format!("# {}", schema.description));
}
for opt in &schema.options {
let req = if opt.required { "required" } else { "optional" };
lines.push(format!(
"# {} ({:?}, {})",
opt.name, opt.option_type, req
));
}
}
lines.push(String::new());
}
// Client config
lines.push("# Client configuration (requires client feature)".to_string());
lines.push("#client:".to_string());
lines.push("# url: null".to_string());
lines.push("# username: null".to_string());
lines.push("# password: null".to_string());
lines.push("# jwt: null".to_string());
// Print
for line in &lines {
println!("{line}");
}
Ok(())
}
struct ListColumn {
name: String,
label: String,
align: String,
}
fn default_list_format() -> Vec<ListColumn> {
vec![
ListColumn {
name: "id".into(),
label: "Item".into(),
align: "right".into(),
},
ListColumn {
name: "time".into(),
label: "Time".into(),
align: "right".into(),
},
ListColumn {
name: "size".into(),
label: "Size".into(),
align: "right".into(),
},
ListColumn {
name: "meta:text_line_count".into(),
label: "Lines".into(),
align: "right".into(),
},
ListColumn {
name: "tags".into(),
label: "Tags".into(),
align: "left".into(),
},
ListColumn {
name: "meta:hostname_short".into(),
label: "Host".into(),
align: "left".into(),
},
ListColumn {
name: "meta:command".into(),
label: "Command".into(),
align: "left".into(),
},
]
}
fn build_meta_plugins_section(schemas: &[crate::common::schema::PluginSchema]) -> Vec<String> {
let mut lines = Vec::new();
for (i, schema) in schemas.iter().enumerate() {
let is_default = schema.name == "env";
let prefix = if is_default { "" } else { "# " };
if i > 0 {
lines.push(format!("{prefix}# --- {name} ---", name = schema.name));
}
lines.push(format!("{prefix}- name: {}", schema.name));
// Options
if !schema.options.is_empty() {
lines.push(format!("{prefix} options:"));
for opt in &schema.options {
if let Some(ref default) = opt.default {
let default_str = format_yaml_value(default);
lines.push(format!("{prefix} {}: {}", opt.name, default_str));
} else if opt.required {
lines.push(format!("{prefix} {}: null # required", opt.name));
}
}
} else {
lines.push(format!("{prefix} options: {{}}"));
}
// Outputs
if !schema.outputs.is_empty() {
lines.push(format!("{prefix} outputs:"));
for output in &schema.outputs {
lines.push(format!("{prefix} {}: {}", output.name, output.name));
}
} else {
lines.push(format!("{prefix} outputs: {{}}"));
}
}
lines
}
fn format_yaml_value(value: &serde_yaml::Value) -> String {
match value {
serde_yaml::Value::Null => "null".into(),
serde_yaml::Value::Bool(b) => b.to_string(),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::String(s) => {
if s.contains(' ') || s.contains(':') || s.contains('#') {
format!("\"{s}\"")
} else {
s.clone()
}
}
serde_yaml::Value::Sequence(_) | serde_yaml::Value::Mapping(_) => {
serde_yaml::to_string(value)
.unwrap_or_default()
.trim()
.to_string()
}
serde_yaml::Value::Tagged(_) => serde_yaml::to_string(value)
.unwrap_or_default()
.trim()
.to_string(),
}
}
fn compression_description(name: &str) -> &str {
match name {
"lz4" => "Fast compression (native)",
"gzip" => "Good compression ratio (native)",
"bzip2" => "High compression (requires bzip2 binary)",
"xz" => "Very high compression (requires xz binary)",
"zstd" => "Modern fast compression (requires zstd binary)",
"raw" => "No compression (alias: none)",
_ => "",
}
}

View File

@@ -1,114 +1,111 @@
use anyhow::anyhow;
use std::io::{Read, Write};
use anyhow::{Context, Result, anyhow};
use std::io::Write;
use crate::compression_engine::{CompressionType, get_compression_engine};
use crate::common::is_binary;
use crate::common::PIPESIZE;
use crate::common::is_binary::is_binary;
use crate::config;
use crate::filter_plugin::FilterChain;
use crate::services::item_service::ItemService;
use clap::Command;
use is_terminal::IsTerminal;
use std::io::Read;
use std::path::PathBuf;
use std::str::FromStr;
/// Handles the get mode: retrieves and streams item content to stdout, applying filters if specified.
///
/// # Arguments
///
/// * `cmd` - Clap command for error handling.
/// * `settings` - Global settings, including force output flag.
/// * `ids` - List of item IDs (at most one).
/// * `tags` - List of tags to match (mutually exclusive with IDs).
/// * `conn` - Database connection.
/// * `data_path` - Path to data directory.
/// * `filter_chain` - Optional pre-parsed filter chain to apply to content.
///
/// # Returns
///
/// `Result<()>` on success, or an error if item not found or output fails.
pub fn mode_get(
cmd: &mut Command,
args: &crate::Args,
ids: &mut Vec<i64>,
tags: &mut Vec<String>,
settings: &config::Settings,
ids: &mut [i64],
tags: &mut [String],
conn: &mut rusqlite::Connection,
data_path: PathBuf,
) -> anyhow::Result<()> {
filter_chain: Option<FilterChain>,
) -> Result<()> {
if !ids.is_empty() && !tags.is_empty() {
cmd.error(clap::error::ErrorKind::InvalidValue, "Both ID and tags given, you must supply exactly one ID or at least one tag when using --get").exit();
cmd.error(
clap::error::ErrorKind::InvalidValue,
"Both ID and tags given, you must supply either IDs or tags when using --get",
)
.exit();
} else if ids.len() > 1 {
cmd.error(clap::error::ErrorKind::InvalidValue, "More than one ID given, you must supply exactly one ID or at least one tag when using --get").exit();
cmd.error(
clap::error::ErrorKind::InvalidValue,
"More than one ID given, you must supply exactly one ID when using --get",
)
.exit();
}
// If both are empty, find_item will find the last item
let mut meta: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for item in args.item.meta.iter() {
let item = item.clone();
meta.insert(item.key, item.value);
}
let item_service = ItemService::new(data_path.clone());
let item_with_meta = item_service
.find_item(conn, ids, tags, &settings.meta_filter())
.map_err(|e| anyhow!("Unable to find matching item in database: {}", e))?;
let item_maybe = match tags.is_empty() && meta.is_empty() {
true => match ids.iter().next() {
Some(item_id) => crate::db::get_item(conn, *item_id)?,
None => crate::db::get_item_last(conn)?,
},
false => crate::db::get_item_matching(conn, tags, &meta)?,
};
let item_id = item_with_meta.item.id.context("Item missing ID")?;
if let Some(item) = item_maybe {
let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?;
// Validate that item ID is positive to prevent path traversal issues
if item_id <= 0 {
return Err(anyhow!("Invalid item ID: {}", item_id));
}
let mut item_path = data_path.clone();
item_path.push(item_id.to_string());
// Determine if we should detect binary data
let mut detect_binary = !settings.force && std::io::stdout().is_terminal();
// Determine if we should detect binary data
let mut detect_binary = !args.options.force && is_stdout_tty();
// If we're detecting binary and there's binary metadata, check it
if detect_binary {
let item_meta = crate::db::get_item_meta(conn, &item)?;
let binary_meta = item_meta.into_iter().find(|meta| meta.name == "binary");
if let Some(binary_meta) = binary_meta {
if binary_meta.value == "false" {
// If metadata says it's not binary, don't detect
detect_binary = false;
} else if binary_meta.value == "true" {
// If metadata says it's binary, error immediately
return Err(anyhow!("Refusing to output binary data to TTY, use --force to override"));
}
if detect_binary {
let meta_map = item_with_meta.meta_as_map();
if let Some(text_val) = meta_map.get("text") {
if text_val == "true" {
detect_binary = false;
} else if text_val == "false" {
return Err(anyhow!(
"Refusing to output binary data to TTY, use --force to override"
));
}
}
}
let compression_type = CompressionType::from_str(&item.compression)?;
let compression_engine = get_compression_engine(compression_type)?;
// If we need to detect binary, read first 4KB and check
if detect_binary {
// Open the file through compression engine to read first 4KB
let mut reader = compression_engine.open(item_path.clone())?;
let mut buffer = [0u8; 4096];
let bytes_read = reader.read(&mut buffer)?;
// Check if this data is binary
if is_binary(&buffer[..bytes_read]) {
return Err(anyhow!("Refusing to output binary data to TTY, use --force to override"));
}
// If not binary, output the data we've read
std::io::stdout().write_all(&buffer[..bytes_read])?;
// Continue reading and outputting the rest of the data
let mut stdout = std::io::stdout();
std::io::copy(&mut reader, &mut stdout)?;
} else {
// No binary detection needed, just output the data
compression_engine.cat(item_path.clone())?;
if detect_binary {
// Binary detection: sample first 8KB, then create a fresh reader for the full output.
let (mut sample_reader, _, _) = item_service
.get_item_content_info_streaming_with_item(item_with_meta, filter_chain.as_ref())?;
let mut sample_buffer = vec![0; PIPESIZE];
let bytes_read = sample_reader.read(&mut sample_buffer)?;
if is_binary(&sample_buffer[..bytes_read]) {
return Err(anyhow!(
"Refusing to output binary data to TTY, use --force to override"
));
}
Ok(())
// Create fresh reader for actual output (sampling consumed the first reader)
let (reader, _, _) = item_service.get_item_content_info_streaming_with_chain(
conn,
item_id,
filter_chain.as_ref(),
)?;
stream_to_stdout(reader)?;
} else {
Err(anyhow!("Unable to find matching item in database"))
// No binary detection needed, use the already-fetched item with meta
let (reader, _, _) = item_service
.get_item_content_info_streaming_with_item(item_with_meta, filter_chain.as_ref())?;
stream_to_stdout(reader)?;
}
Ok(())
}
fn is_stdout_tty() -> bool {
#[cfg(unix)]
unsafe {
libc::isatty(libc::STDOUT_FILENO) != 0
}
#[cfg(windows)]
unsafe {
let stdout_handle = winapi::um::processenv::GetStdHandle(winapi::um::winbase::STD_OUTPUT_HANDLE);
let mut console_mode: winapi::shared::minwindef::DWORD = 0;
winapi::um::consoleapi::GetConsoleMode(stdout_handle, &mut console_mode) != 0
}
// Fallback for non-unix platforms or if we can't determine
#[cfg(not(any(unix, windows)))]
false
fn stream_to_stdout(mut reader: Box<dyn Read + Send>) -> Result<()> {
let mut stdout = std::io::stdout();
crate::common::stream_copy(&mut reader, |chunk| {
stdout.write_all(chunk)?;
Ok(())
})?;
Ok(())
}

192
src/modes/import.rs Normal file
View File

@@ -0,0 +1,192 @@
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Utc};
use clap::Command;
use log::debug;
use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::str::FromStr;
use crate::common::PIPESIZE;
use crate::compression_engine::CompressionType;
use crate::config;
use crate::db;
use crate::import_tar;
use crate::modes::common::ImportMeta;
/// Import items from a `.keep.tar` archive or legacy `.meta.yml` file.
///
/// For `.keep.tar` files, all items are imported in their original ID order,
/// each receiving a new auto-incremented ID from the database.
/// For `.meta.yml` files, the legacy single-item import is used.
pub fn mode_import(
cmd: &mut Command,
settings: &config::Settings,
import_path: &str,
conn: &mut rusqlite::Connection,
data_path: PathBuf,
) -> Result<()> {
let path = PathBuf::from(import_path);
if import_path.ends_with(".keep.tar") {
// New tar-based import
let imported_ids = import_tar::import_from_tar(&path, conn, &data_path)?;
if !settings.quiet {
println!(
"KEEP: Imported {} item(s): {:?}",
imported_ids.len(),
imported_ids
);
}
debug!(
"IMPORT: Imported {} items from {}",
imported_ids.len(),
import_path
);
} else if import_path.ends_with(".meta.yml") {
// Legacy single-item import
import_legacy(cmd, settings, import_path, conn, data_path)?;
} else {
cmd.error(
clap::error::ErrorKind::InvalidValue,
format!("Unsupported import format: {}", import_path),
)
.exit();
}
Ok(())
}
/// Legacy single-item import from a `.meta.yml` file.
fn import_legacy(
cmd: &mut Command,
settings: &config::Settings,
meta_file: &str,
conn: &mut rusqlite::Connection,
data_path: PathBuf,
) -> Result<()> {
// Read metadata
let meta_yaml = fs::read_to_string(meta_file)
.with_context(|| format!("Cannot read metadata file: {meta_file}"))?;
let import_meta: ImportMeta = serde_yaml::from_str(&meta_yaml)
.with_context(|| format!("Cannot parse metadata file: {meta_file}"))?;
// Validate compression type
CompressionType::from_str(&import_meta.compression).map_err(|_| {
anyhow!(
"Invalid compression type '{}' in metadata file",
import_meta.compression
)
})?;
debug!(
"IMPORT: Parsed meta: ts={}, compression={}, tags={:?}",
import_meta.ts, import_meta.compression, import_meta.tags
);
// Create item with original timestamp
let item = db::insert_item_with_ts(conn, import_meta.ts, &import_meta.compression)?;
let item_id = item.id.context("New item missing ID")?;
debug!(
"IMPORT: Created item {} with compression {}",
item_id, import_meta.compression
);
// Set tags
if !import_meta.tags.is_empty() {
db::set_item_tags(conn, item.clone(), &import_meta.tags)?;
debug!("IMPORT: Set {} tags", import_meta.tags.len());
}
// Write data to storage using streaming copy
let mut item_path = data_path;
item_path.push(item_id.to_string());
let data_size: i64 = if let Some(ref data_file) = settings.import_data_file {
// Stream from file to storage using fixed-size buffers
let mut reader = fs::File::open(data_file)
.with_context(|| format!("Cannot read data file: {}", data_file.display()))?;
let mut writer = fs::File::create(&item_path)
.with_context(|| format!("Cannot create item file: {}", item_path.display()))?;
let mut buf = [0u8; PIPESIZE];
let mut total = 0i64;
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
writer.write_all(&buf[..n])?;
total += n as i64;
}
total
} else {
// Stream from stdin to storage
let mut writer = fs::File::create(&item_path)
.with_context(|| format!("Cannot create item file: {}", item_path.display()))?;
let mut stdin = std::io::stdin().lock();
let mut buf = [0u8; PIPESIZE];
let mut total = 0i64;
loop {
let n = stdin.read(&mut buf)?;
if n == 0 {
break;
}
writer.write_all(&buf[..n])?;
total += n as i64;
}
total
};
if data_size == 0 {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"No data provided (empty file or stdin)",
)
.exit();
}
debug!(
"IMPORT: Wrote {} bytes to {}",
data_size,
item_path.display()
);
// Set metadata
for (key, value) in &import_meta.metadata {
db::query_upsert_meta(
conn,
db::Meta {
id: item_id,
name: key.clone(),
value: value.clone(),
},
)?;
}
if !import_meta.metadata.is_empty() {
debug!(
"IMPORT: Set {} metadata entries",
import_meta.metadata.len()
);
}
// Update item sizes (use imported size if available, otherwise data length)
let size_to_record = import_meta.uncompressed_size.unwrap_or(data_size);
let mut updated_item = item;
updated_item.uncompressed_size = Some(size_to_record);
updated_item.compressed_size = Some(std::fs::metadata(&item_path)?.len() as i64);
updated_item.closed = true;
db::update_item(conn, updated_item)?;
if !settings.quiet {
println!(
"KEEP: Imported item {} tags: {:?}",
item_id, import_meta.tags
);
}
Ok(())
}

View File

@@ -1,58 +1,95 @@
use crate::db::Item;
use crate::modes::common::{format_size, get_output_format, OutputFormat};
use anyhow::anyhow;
use serde_json;
use serde_yaml;
use serde::{Deserialize, Serialize};
use crate::config;
use crate::modes::common::{DisplayItemInfo, OutputFormat, format_size, render_item_info_table};
use crate::services::types::ItemWithMeta;
use anyhow::{Context, Result, anyhow};
use clap::Command;
use clap::error::ErrorKind;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::str::FromStr;
use crate::compression_engine::CompressionType;
use crate::db::{get_item, get_item_last, get_item_matching};
use crate::modes::common::get_format_box_chars_no_border_line_separator;
use crate::services::item_service::ItemService;
use chrono::prelude::*;
use is_terminal::IsTerminal;
use prettytable::format;
use prettytable::{Attr, Cell, Row, Table};
/// Displays detailed information about an item or the last item if no ID/tags specified.
///
/// Supports table, JSON, or YAML output formats. Validates input (at most one ID, no mixing IDs/tags).
/// Uses ItemService to fetch the item and displays via helpers.
///
/// # Arguments
///
/// * `cmd` - Mutable Clap command for error handling and exiting on invalid args.
/// * `settings` - Application settings for output formatting and human-readable sizes.
/// * `ids` - Mutable vector of item IDs (at most one; cleared if tags used).
/// * `tags` - Mutable vector of tags (mutually exclusive with IDs).
/// * `conn` - Mutable database connection for querying items.
/// * `data_path` - Path to data directory for file metadata.
///
/// # Returns
///
/// `Ok(())` on success, or `Err(anyhow::Error)` if item not found or DB query fails.
///
/// # Errors
///
/// * Clap errors if invalid args (e.g., multiple IDs).
/// * Anyhow error if no matching item found.
///
/// # Examples
///
/// ```ignore
/// // Example usage requires Command, Settings, Connection, and PathBuf instances
/// mode_info(&mut cmd, &settings, &mut vec![123], &mut vec![], &mut conn, data_path)?;
/// ```
pub fn mode_info(
cmd: &mut Command,
args: &crate::Args,
ids: &mut Vec<i64>,
tags: &mut Vec<String>,
settings: &config::Settings,
ids: &mut [i64],
tags: &mut [String],
conn: &mut rusqlite::Connection,
data_path: PathBuf,
) -> anyhow::Result<()> {
) -> Result<()> {
// For --info, we can use either IDs or tags, but not both
if !ids.is_empty() && !tags.is_empty() {
cmd.error(ErrorKind::InvalidValue, "Both ID and tags given, you must supply exactly one ID or atleast one tag when using --info").exit();
cmd.error(
ErrorKind::InvalidValue,
"Both ID and tags given, you must supply either IDs or tags when using --info",
)
.exit();
} else if ids.len() > 1 {
cmd.error(ErrorKind::InvalidValue, "More than one ID given, you must supply exactly one ID or atleast one tag when using --info").exit();
cmd.error(
ErrorKind::InvalidValue,
"More than one ID given, you must supply exactly one ID when using --info",
)
.exit();
}
// If both are empty, find_item will find the last item
let mut meta: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for item in args.item.meta.iter() {
let item = item.clone();
meta.insert(item.key, item.value);
}
let item_service = ItemService::new(data_path.clone());
let item_with_meta = item_service
.find_item(conn, ids, tags, &settings.meta_filter())
.map_err(|e| anyhow!("Unable to find matching item in database: {}", e))?;
let item_maybe = match tags.is_empty() && meta.is_empty() {
true => match ids.iter().next() {
Some(item_id) => get_item(conn, *item_id)?,
None => get_item_last(conn)?,
},
false => get_item_matching(conn, tags, &meta)?,
};
match item_maybe {
Some(item) => show_item(item, args, conn, data_path),
None => Err(anyhow!("Unable to find matching item in database")),
}
show_item(item_with_meta, settings, data_path)
}
#[derive(Serialize, Deserialize)]
struct ItemInfo {
#[derive(Debug, Serialize, Deserialize)]
/// Structured representation of item information for JSON/YAML output.
///
/// This struct serializes item details including ID, timestamp, sizes, compression, tags, and metadata
/// for non-table output formats.
///
/// # Fields
///
/// * `id` - The unique item ID.
/// * `timestamp` - Formatted timestamp string.
/// * `path` - Full file path to the item.
/// * `stream_size` - Original uncompressed size in bytes (optional).
/// * `stream_size_formatted` - Human-readable stream size.
/// * `compression` - Compression type used.
/// * `file_size` - Compressed file size in bytes (optional).
/// * `file_size_formatted` - Human-readable file size.
/// * `tags` - List of associated tags.
/// * `meta` - Metadata key-value pairs.
pub struct ItemInfo {
id: i64,
timestamp: String,
path: String,
@@ -65,138 +102,143 @@ struct ItemInfo {
meta: std::collections::HashMap<String, String>,
}
/// Displays item information in table format or delegates to structured output.
///
/// Builds a comfy-table for tabular display or calls structured helper for JSON/YAML.
/// Handles file size via metadata and formats tags/meta accordingly.
///
/// # Arguments
///
/// * `item_with_meta` - Item with associated metadata and tags.
/// * `settings` - Application settings for formatting (e.g., human-readable sizes).
/// * `data_path` - Path to data directory for calculating compressed file size.
///
/// # Returns
///
/// `Ok(())` on success, or `Err(anyhow::Error)` if path resolution fails.
///
/// # Errors
///
/// * Anyhow error if item path cannot be stringified.
///
/// # Examples
///
/// ```ignore
/// // Example usage requires ItemWithMeta, Settings, and PathBuf instances
/// show_item(item_with_meta, &settings, data_path)?;
/// ```
fn show_item(
item: Item, // Using the provided struct definition
args: &crate::Args,
conn: &mut rusqlite::Connection,
item_with_meta: ItemWithMeta,
settings: &config::Settings,
data_path: PathBuf,
) -> anyhow::Result<()> {
let item_id = item.id.unwrap(); // Consider using if let or expect for Option
let item_tags: Vec<String> = crate::db::get_item_tags(conn, &item)?
.into_iter()
.map(|x| x.name)
.collect();
let output_format = get_output_format(args);
) -> Result<()> {
let output_format = crate::modes::common::settings_output_format(settings);
if output_format != OutputFormat::Table {
return show_item_structured(item, args, conn, data_path, output_format);
return show_item_structured(item_with_meta, settings, data_path, output_format);
}
let mut table = Table::new();
if std::io::stdout().is_terminal() {
table.set_format(get_format_box_chars_no_border_line_separator());
} else {
table.set_format(*format::consts::FORMAT_NO_BORDER_LINE_SEPARATOR);
}
let item_tags = item_with_meta.tag_names();
let item = item_with_meta.item;
let item_id = item.id.context("Item missing ID")?;
table.add_row(Row::new(vec![
Cell::new("ID").with_style(Attr::Bold),
Cell::new(&item_id.to_string()),
]));
let mut item_path_buf = data_path.clone();
item_path_buf.push(item_id.to_string());
let ts_cell = Cell::new(&item.ts.with_timezone(&Local).format("%F %T %Z").to_string());
table.add_row(Row::new(vec![
Cell::new("Timestamp").with_style(Attr::Bold),
ts_cell,
]));
let mut item_path_buf = data_path.clone(); // Renamed to avoid conflict if item_path is used later
item_path_buf.push(item.id.unwrap().to_string()); // Again, consider safer unwrap
table.add_row(Row::new(vec![
Cell::new("Path").with_style(Attr::Bold),
Cell::new(item_path_buf.to_str().expect("Unable to get item path")),
]));
let size_cell = match item.size {
Some(size) => Cell::new(format_size(size as u64, args.options.human_readable).as_str()),
None => Cell::new("Missing")
.with_style(Attr::ForegroundColor(prettytable::color::RED))
.with_style(Attr::Bold),
let size_str = match item.uncompressed_size {
Some(size) => format_size(size as u64, settings.human_readable),
None => "Missing".to_string(),
};
table.add_row(Row::new(vec![
Cell::new("Stream Size").with_style(Attr::Bold),
size_cell,
]));
// compression_type is CompressionType due to '?'
let compression_type_val = CompressionType::from_str(&item.compression)
.map_err(|e| anyhow!("Failed to parse compression type: {}", e))?;
table.add_row(Row::new(vec![
Cell::new("Compression").with_style(Attr::Bold),
Cell::new(&compression_type_val.to_string()),
]));
let file_size_cell = match item_path_buf.metadata() {
Ok(metadata) => {
Cell::new(format_size(metadata.len(), args.options.human_readable).as_str())
}
Err(_) => Cell::new("Missing")
.with_style(Attr::ForegroundColor(prettytable::color::RED))
.with_style(Attr::Bold),
let file_size_str = match item_path_buf.metadata() {
Ok(metadata) => format_size(metadata.len(), settings.human_readable),
Err(_) => "Missing".to_string(),
};
table.add_row(Row::new(vec![
Cell::new("File Size").with_style(Attr::Bold),
file_size_cell,
]));
table.add_row(Row::new(vec![
Cell::new("Tags").with_style(Attr::Bold),
Cell::new(&item_tags.join(" ")),
]));
let metadata: Vec<(String, String)> = item_with_meta
.meta
.iter()
.map(|m| (m.name.clone(), m.value.clone()))
.collect();
for meta in crate::db::get_item_meta(conn, &item)? {
let meta_name = format!("Meta: {}", &meta.name);
table.add_row(Row::new(vec![
Cell::new(meta_name.as_str()).with_style(Attr::Bold),
Cell::new(&meta.value),
]));
}
let display = DisplayItemInfo {
id: item_id,
timestamp: item.ts.with_timezone(&Local).format("%F %T %Z").to_string(),
path: item_path_buf
.to_str()
.ok_or_else(|| anyhow::anyhow!("non-UTF-8 item path"))?
.to_string(),
stream_size: size_str,
compression: item.compression.clone(),
file_size: file_size_str,
tags: item_tags,
metadata,
};
table.printstd();
render_item_info_table(&display, &settings.table_config);
Ok(())
}
/// Displays item information in structured JSON or YAML format.
///
/// Serializes ItemInfo and prints pretty-formatted output. Handles file metadata for sizes.
///
/// # Arguments
///
/// * `item_with_meta` - Item with metadata and tags.
/// * `settings` - Settings for size formatting (human-readable).
/// * `data_path` - Data path for compressed file size calculation.
/// * `output_format` - JSON or YAML (Table is unreachable here).
///
/// # Returns
///
/// `Ok(())` on success, or `Err(anyhow::Error)` if serialization or path fails.
///
/// # Errors
///
/// * Serde errors during JSON/YAML serialization.
/// * Anyhow error if file metadata unavailable.
///
/// # Examples
///
/// ```ignore
/// // Example usage requires ItemWithMeta, Settings, PathBuf, and OutputFormat instances
/// show_item_structured(item_with_meta, &settings, data_path, OutputFormat::Json)?;
/// ```
fn show_item_structured(
item: Item,
args: &crate::Args,
conn: &mut rusqlite::Connection,
item_with_meta: ItemWithMeta,
settings: &config::Settings,
data_path: PathBuf,
output_format: OutputFormat,
) -> anyhow::Result<()> {
let item_id = item.id.unwrap();
let item_tags: Vec<String> = crate::db::get_item_tags(conn, &item)?
.into_iter()
.map(|x| x.name)
.collect();
) -> Result<()> {
let item_tags = item_with_meta.tag_names();
let meta_map = item_with_meta.meta_as_map();
let item = item_with_meta.item;
let item_id = item.id.context("Item missing ID")?;
let mut item_path_buf = data_path.clone();
item_path_buf.push(item_id.to_string());
let file_size = item_path_buf.metadata().map(|m| m.len()).ok();
let file_size_formatted = match file_size {
Some(size) => format_size(size, args.options.human_readable),
Some(size) => format_size(size, settings.human_readable),
None => "Missing".to_string(),
};
let stream_size_formatted = match item.size {
Some(size) => format_size(size as u64, args.options.human_readable),
let stream_size_formatted = match item.uncompressed_size {
Some(size) => format_size(size as u64, settings.human_readable),
None => "Missing".to_string(),
};
let mut meta_map = std::collections::HashMap::new();
for meta in crate::db::get_item_meta(conn, &item)? {
meta_map.insert(meta.name, meta.value);
}
let item_info = ItemInfo {
id: item_id,
timestamp: item.ts.with_timezone(&chrono::Local).format("%F %T %Z").to_string(),
timestamp: item
.ts
.with_timezone(&chrono::Local)
.format("%F %T %Z")
.to_string(),
path: item_path_buf.to_str().unwrap_or("").to_string(),
stream_size: item.size.map(|s| s as u64),
stream_size: item.uncompressed_size.map(|s| s as u64),
stream_size_formatted,
compression: item.compression,
file_size,
@@ -205,15 +247,7 @@ fn show_item_structured(
meta: meta_map,
};
match output_format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&item_info)?);
}
OutputFormat::Yaml => {
println!("{}", serde_yaml::to_string(&item_info)?);
}
OutputFormat::Table => unreachable!(),
}
crate::modes::common::print_serialized(&item_info, &output_format)?;
Ok(())
}

View File

@@ -1,228 +1,301 @@
use crate::db::{get_items, get_items_matching};
/// List mode implementation.
///
/// This module provides the functionality to list stored items with customizable
/// formatting, filtering by tags, and support for different output formats
/// including table, JSON, and YAML.
use crate::config;
use crate::modes::common::ColumnType;
use crate::modes::common::{size_column, string_column, get_output_format, OutputFormat};
use crate::modes::common::{OutputFormat, apply_color, apply_table_attribute, format_size};
use crate::services::item_service::ItemService;
use crate::services::types::ItemWithMeta;
use anyhow::{Context, Result};
use comfy_table::CellAlignment;
use comfy_table::{Attribute, Cell, Color, Row};
use serde::{Deserialize, Serialize};
use serde_json;
use serde_yaml;
use anyhow::anyhow;
use log::debug;
use prettytable::color;
use prettytable::row;
use prettytable::format::Alignment;
use prettytable::{Attr, Cell, Row, Table};
/// Structure representing a list item for structured output formats.
///
/// This struct holds all the information needed to serialize an item for JSON or
/// YAML output in list mode.
#[derive(Serialize, Deserialize)]
struct ListItem {
/// Item ID.
///
/// The unique identifier for the item.
id: Option<i64>,
/// Timestamp.
///
/// The formatted timestamp string for the item.
time: String,
/// Size in bytes.
///
/// The raw size of the item content.
size: Option<u64>,
/// Formatted size.
///
/// Human-readable size string.
size_formatted: String,
/// Compression type.
///
/// The compression algorithm used for the item.
compression: String,
/// File size in bytes.
///
/// The size of the stored file on disk.
file_size: Option<u64>,
/// Formatted file size.
///
/// Human-readable file size string.
file_size_formatted: String,
/// File path.
///
/// The full path to the item's storage file.
file_path: String,
/// Tags.
///
/// Vector of tag names associated with the item.
tags: Vec<String>,
/// Metadata.
///
/// HashMap of metadata key-value pairs.
meta: std::collections::HashMap<String, String>,
}
/// Main list mode function.
///
/// This function handles the listing of items based on tags, applying formatting
/// and output options from settings. It supports table, JSON, and YAML output formats.
///
/// # Arguments
///
/// * `cmd` - Mutable reference to the Clap command for error handling.
/// * `settings` - Reference to application settings.
/// * `ids` - Mutable vector of item IDs (should be empty for list mode).
/// * `tags` - Reference to vector of tags for filtering.
/// * `conn` - Mutable reference to database connection.
/// * `data_path` - Path to the data directory.
///
/// # Returns
///
/// * `Result<()>` - Success or error if listing fails.
pub fn mode_list(
cmd: &mut clap::Command,
args: &crate::Args,
ids: &mut Vec<i64>,
tags: &Vec<String>,
_cmd: &mut clap::Command,
settings: &config::Settings,
ids: &mut [i64],
tags: &[String],
conn: &mut rusqlite::Connection,
data_path: std::path::PathBuf,
) -> anyhow::Result<()> {
if !ids.is_empty() {
cmd.error(
clap::error::ErrorKind::InvalidValue,
"ID given, you can only supply tags when using --list",
)
.exit();
) -> Result<()> {
let item_service = ItemService::new(data_path.clone());
let items_with_meta = item_service.get_items(conn, ids, tags, &settings.meta_filter())?;
if settings.ids_only {
for item_with_meta in &items_with_meta {
if let Some(id) = item_with_meta.item.id {
println!("{id}");
}
}
return Ok(());
}
let mut meta: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for item in args.item.meta.iter() {
let item = item.clone();
meta.insert(item.key, item.value);
}
let items = match tags.is_empty() && meta.is_empty() {
true => get_items(conn)?,
false => get_items_matching(conn, tags, &meta)?,
};
debug!("MAIN: Items: {:?}", items);
// Collect all item IDs for batch queries
let item_ids: Vec<i64> = items.iter().map(|item| item.id.unwrap()).collect();
// Fetch all tags for all items in a single query
let all_tags = crate::db::get_tags_for_items(conn, &item_ids)?;
let mut tags_by_item: std::collections::HashMap<i64, Vec<String>> =
std::collections::HashMap::new();
// Convert Tag structs to just names
for (item_id, tags) in all_tags {
let tag_names: Vec<String> = tags.into_iter().map(|tag| tag.name).collect();
tags_by_item.insert(item_id, tag_names);
}
// Fetch all metadata for all items in a single query
let meta_by_item = crate::db::get_meta_for_items(conn, &item_ids)?;
let output_format = get_output_format(args);
let output_format = crate::modes::common::settings_output_format(settings);
if output_format != OutputFormat::Table {
return show_list_structured(items, tags_by_item, meta_by_item, data_path, args, output_format);
return show_list_structured(items_with_meta, data_path, settings, output_format);
}
let mut table = Table::new();
table.set_format(*prettytable::format::consts::FORMAT_CLEAN);
let mut table = crate::modes::common::create_table_with_config(&settings.table_config);
let list_format = args.options.list_format.split(",");
let mut title_row = row!();
for column in list_format.clone() {
let mut column_format = column.split(":");
let column_name = column_format.next().expect("Unable to parse column name");
let column_type = ColumnType::from_str(column_name)
.map_err(|_| anyhow!("Unknown column {:?}", column_name))?;
if column_type == ColumnType::Meta {
let meta_name = column_format
.next()
.expect("Unable to parse metadata name for meta column");
title_row.add_cell(Cell::new(meta_name).with_style(Attr::Bold));
} else {
title_row.add_cell(Cell::new(&column_type.to_string()).with_style(Attr::Bold));
}
// Create header row
let mut header_cells = Vec::new();
for column in &settings.list_format {
header_cells.push(Cell::new(&column.label).add_attribute(Attribute::Bold));
}
table.set_header(header_cells);
table.set_titles(title_row);
for item_with_meta in items_with_meta {
let tags = item_with_meta.tag_names();
let meta = item_with_meta.meta_as_map();
let item = item_with_meta.item;
for item in items {
let item_id = item.id.unwrap();
let tags = tags_by_item.get(&item_id).unwrap();
let meta = meta_by_item.get(&item_id).unwrap();
let mut item_path = data_path.clone();
item_path.push(item.id.unwrap().to_string());
item_path.push(item.id.context("Item missing ID")?.to_string());
let mut table_row = Row::new(vec![]);
let mut table_row = Row::new();
for column in &settings.list_format {
let column_type = column
.name
.parse::<ColumnType>()
.with_context(|| format!("Unknown column type {:?} in list format", column.name))?;
for column in list_format.clone() {
let mut column_format = column.split(":");
let column_name = column_format.next().expect("Unable to parse column name");
let column_type = ColumnType::from_str(column_name)
.unwrap_or_else(|_| panic!("Unknown column {:?}", column_name));
let mut meta_name: Option<&str> = None;
if column_type == ColumnType::Meta {
meta_name = column_format.next();
if let ColumnType::Meta = column_type {
let parts: Vec<&str> = column.name.split(':').collect();
if parts.len() > 1 {
meta_name = Some(parts[1]);
}
}
let column_width: usize = match column_format.next() {
Some(len) => len.parse().unwrap_or(0),
None => 0,
};
let cell = match column_type {
ColumnType::Id => Cell::new_align(
&string_column(item.id.unwrap_or(0).to_string(), column_width),
Alignment::RIGHT,
),
ColumnType::Time => Cell::new(&string_column(
item.ts
.with_timezone(&chrono::Local)
.format("%F %T")
.to_string(),
column_width,
)),
ColumnType::Size => match item.size {
Some(size) => Cell::new_align(
&size_column(size as u64, args.options.human_readable, column_width),
Alignment::RIGHT,
),
let cell_content = match column_type {
ColumnType::Id => item.id.unwrap_or(0).to_string(),
ColumnType::Time => item
.ts
.with_timezone(&chrono::Local)
.format("%F %T")
.to_string(),
ColumnType::Size => match item.uncompressed_size {
Some(size) => format_size(size as u64, settings.human_readable),
None => match item_path.metadata() {
Ok(_) => Cell::new_align("Unknown", Alignment::RIGHT)
.with_style(Attr::ForegroundColor(color::YELLOW))
.with_style(Attr::Bold),
Err(_) => Cell::new_align("Missing", Alignment::RIGHT)
.with_style(Attr::ForegroundColor(color::RED))
.with_style(Attr::Bold),
Ok(_) => "Unknown".to_string(),
Err(e) => {
log::warn!("File missing or inaccessible: {}", e);
"Missing".to_string()
}
},
},
ColumnType::Compression => {
Cell::new(&string_column(item.compression.to_string(), column_width))
},
ColumnType::Compression => item.compression.to_string(),
ColumnType::FileSize => match item_path.metadata() {
Ok(metadata) => Cell::new_align(
&size_column(metadata.len(), args.options.human_readable, column_width),
Alignment::RIGHT,
),
Err(_) => Cell::new_align("Missing", Alignment::RIGHT)
.with_style(Attr::ForegroundColor(color::RED))
.with_style(Attr::Bold),
Ok(metadata) => format_size(metadata.len(), settings.human_readable),
Err(e) => {
log::warn!("File missing or inaccessible: {}", e);
"Missing".to_string()
}
},
ColumnType::FilePath => Cell::new(&string_column(
item_path.clone().into_os_string().into_string().unwrap(),
column_width,
)),
ColumnType::Tags => Cell::new(&string_column(tags.join(" "), column_width)),
ColumnType::FilePath => item_path
.clone()
.into_os_string()
.into_string()
.unwrap_or_else(|os| os.to_string_lossy().into_owned()),
ColumnType::Tags => tags.join(" "),
ColumnType::Meta => match meta_name {
Some(meta_name) => match meta.get(meta_name) {
Some(meta_value) => {
Cell::new(&string_column(meta_value.to_string(), column_width))
}
None => Cell::new(""),
Some(meta_value) => meta_value.to_string(),
None => "".to_string(),
},
None => Cell::new(""),
None => "".to_string(),
},
};
// Truncate content to max 3 lines
let mut cell_lines: Vec<String> =
cell_content.split('\n').map(|s| s.to_string()).collect();
if cell_lines.len() > 3 {
cell_lines.truncate(3);
// Add ellipsis to the last line if we truncated
if let Some(last_line) = cell_lines.last_mut() {
if last_line.len() > 3 {
last_line.truncate(last_line.len() - 3);
}
last_line.push_str("...");
}
}
let truncated_content = cell_lines.join("\n");
let mut cell = Cell::new(truncated_content);
// Apply column-specific styling
if let Some(fg_color) = &column.fg_color {
cell = apply_color(cell, fg_color, true);
}
if let Some(bg_color) = &column.bg_color {
cell = apply_color(cell, bg_color, false);
}
for attribute in &column.attributes {
cell = apply_table_attribute(cell, attribute);
}
// Apply padding if specified
if let Some((_left_padding, _right_padding)) = column.padding {
// Note: comfy-table doesn't directly support padding, so we'd need to handle this
// by adding spaces to the content, or use a different approach
}
// Apply styling for specific cases
match column_type {
ColumnType::Size => {
if item.uncompressed_size.is_none() {
if item_path.metadata().is_ok() {
cell = cell
.fg(comfy_table::Color::Yellow)
.add_attribute(Attribute::Bold);
} else {
cell = cell
.fg(comfy_table::Color::Red)
.add_attribute(Attribute::Bold);
}
}
}
ColumnType::FileSize => {
if item_path.metadata().is_err() {
cell = cell
.fg(comfy_table::Color::Red)
.add_attribute(Attribute::Bold);
}
}
_ => {}
}
// Apply alignment
cell = match column.align {
crate::config::ColumnAlignment::Right => cell.set_alignment(CellAlignment::Right),
crate::config::ColumnAlignment::Left => cell.set_alignment(CellAlignment::Left),
crate::config::ColumnAlignment::Center => cell.set_alignment(CellAlignment::Center),
};
table_row.add_cell(cell);
}
table.add_row(table_row);
}
table.printstd();
println!(
"{}",
crate::modes::common::trim_lines_end(&table.trim_fmt())
);
Ok(())
}
fn show_list_structured(
items: Vec<crate::db::Item>,
tags_by_item: std::collections::HashMap<i64, Vec<String>>,
meta_by_item: std::collections::HashMap<i64, std::collections::HashMap<String, String>>,
items_with_meta: Vec<ItemWithMeta>,
data_path: std::path::PathBuf,
args: &crate::Args,
settings: &config::Settings,
output_format: OutputFormat,
) -> anyhow::Result<()> {
) -> Result<()> {
let mut list_items = Vec::new();
for item in items {
let item_id = item.id.unwrap();
let tags = tags_by_item.get(&item_id).cloned().unwrap_or_default();
let meta = meta_by_item.get(&item_id).cloned().unwrap_or_default();
for item_with_meta in items_with_meta {
let tags = item_with_meta.tag_names();
let meta = item_with_meta.meta_as_map();
let item = item_with_meta.item;
let item_id = item.id.context("Item missing ID")?;
let mut item_path = data_path.clone();
item_path.push(item_id.to_string());
let file_size = item_path.metadata().map(|m| m.len()).ok();
let file_size_formatted = match file_size {
Some(size) => crate::modes::common::format_size(size, args.options.human_readable),
Some(size) => crate::modes::common::format_size(size, settings.human_readable),
None => "Missing".to_string(),
};
let size_formatted = match item.size {
Some(size) => crate::modes::common::format_size(size as u64, args.options.human_readable),
let size_formatted = match item.uncompressed_size {
Some(size) => crate::modes::common::format_size(size as u64, settings.human_readable),
None => "Unknown".to_string(),
};
let list_item = ListItem {
id: item.id,
time: item.ts.with_timezone(&chrono::Local).format("%F %T").to_string(),
size: item.size.map(|s| s as u64),
time: item
.ts
.with_timezone(&chrono::Local)
.format("%F %T")
.to_string(),
size: item.uncompressed_size.map(|s| s as u64),
size_formatted,
compression: item.compression,
file_size,
@@ -235,15 +308,7 @@ fn show_list_structured(
list_items.push(list_item);
}
match output_format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&list_items)?);
}
OutputFormat::Yaml => {
println!("{}", serde_yaml::to_string(&list_items)?);
}
OutputFormat::Table => unreachable!(),
}
crate::modes::common::print_serialized(&list_items, &output_format)?;
Ok(())
}

View File

@@ -1,10 +1,64 @@
#[cfg(feature = "server")]
pub mod server;
#[cfg(feature = "client")]
pub mod client;
/// Common utilities for all modes, including column types and output formatting.
pub mod common;
pub mod delete;
pub mod diff;
pub mod export;
pub mod generate_config;
pub mod get;
pub mod import;
pub mod info;
pub mod list;
pub mod save;
pub mod server;
pub mod status;
pub mod status_plugins;
pub mod update;
/// Column types, output formats, and formatting utilities shared across modes.
pub use common::{ColumnType, OutputFormat, format_size, settings_output_format};
/// Deletes items from the database by ID.
pub use delete::mode_delete;
/// Compares two items and shows differences.
pub use diff::mode_diff;
/// Exports an item to data and metadata files.
pub use export::mode_export;
/// Generates a default configuration file.
pub use generate_config::mode_generate_config;
/// Retrieves and outputs item content.
pub use get::mode_get;
/// Imports an item from metadata and data files.
pub use import::mode_import;
/// Displays detailed information about items.
pub use info::mode_info;
/// Lists items with optional filtering.
pub use list::mode_list;
/// Saves new item content with optional tags and metadata.
pub use save::mode_save;
#[cfg(feature = "server")]
/// Starts the HTTP server for REST API access.
pub use server::mode_server;
/// Shows status of directories and compression support.
pub use status::mode_status;
/// Lists available plugins and their configurations.
pub use status_plugins::mode_status_plugins;
/// Updates an item's tags and metadata by ID.
pub use update::mode_update;

View File

@@ -1,12 +1,24 @@
use anyhow::{anyhow, Result};
use anyhow::Result;
use clap::Command;
use log::debug;
use std::io::{Read, Write, IsTerminal};
use std::io::{Read, Write};
// Import the missing functions from common module
use crate::modes::common::{cmd_args_digest_type, cmd_args_compression_type, cmd_args_meta_plugin_types};
use crate::config;
use crate::services::item_service::ItemService;
fn validate_save_args(cmd: &mut Command, ids: &Vec<i64>) {
/// Validates save mode arguments and exits with error if invalid.
///
/// This function checks that no item IDs are provided for save mode,
/// as save operations create new items rather than modifying existing ones.
///
/// # Arguments
///
/// * `cmd` - Mutable reference to the Clap command for error reporting.
/// * `ids` - Reference to the vector of item IDs (should be empty for save mode).
///
/// # Panics
///
/// Exits the program via Clap error if IDs are provided.
fn validate_save_args(cmd: &mut Command, ids: &[i64]) {
if !ids.is_empty() {
cmd.error(
clap::error::ErrorKind::InvalidValue,
@@ -16,260 +28,107 @@ fn validate_save_args(cmd: &mut Command, ids: &Vec<i64>) {
}
}
fn initialize_tags(tags: &mut Vec<String>) {
if tags.is_empty() {
tags.push("none".to_string());
/// A tee reader that duplicates input to both a reader and a writer as it reads.
///
/// This struct implements the `Read` trait and forwards all read operations to
/// an underlying reader while simultaneously writing the same data to a writer.
/// It's useful for saving content to a file while also echoing it to stdout.
///
/// # Fields
///
/// * `reader` - The underlying reader providing the data source.
/// * `writer` - The writer receiving copies of all read data.
struct TeeReader<R: Read, W: Write> {
reader: R,
writer: W,
}
impl<R: Read, W: Write> Read for TeeReader<R, W> {
/// Reads data from the underlying reader and duplicates it to the writer.
///
/// This implementation reads from the inner reader and then writes the same
/// bytes to the writer. If the read returns 0 bytes (EOF), it returns 0.
///
/// # Arguments
///
/// * `buf` - Buffer to fill with data from the reader.
///
/// # Returns
///
/// * `io::Result<usize>` - Number of bytes read, or an I/O error.
///
/// # Errors
///
/// Returns an error if the underlying read or write operations fail.
///
/// # Examples
///
/// ```ignore
/// let mut tee = TeeReader {
/// reader: std::io::Cursor::new(b"Hello, world!"),
/// writer: std::io::sink(),
/// };
/// let mut buf = [0; 5];
/// let n = tee.read(&mut buf).unwrap();
/// assert_eq!(n, 5);
/// assert_eq!(&buf[..n], b"Hello");
/// ```
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.reader.read(buf)?;
if n > 0 {
self.writer.write_all(&buf[..n])?;
}
Ok(n)
}
}
fn setup_compression_and_plugins(
cmd: &mut Command,
args: &crate::Args,
) -> (crate::compression_engine::CompressionType, Box<dyn crate::compression_engine::CompressionEngine>, Vec<Box<dyn crate::meta_plugin::MetaPlugin>>) {
let digest_type = cmd_args_digest_type(cmd, &args);
debug!("MAIN: Digest type: {:?}", digest_type);
let compression_type = cmd_args_compression_type(cmd, &args);
debug!("MAIN: Compression type: {:?}", compression_type);
let compression_engine =
crate::compression_engine::get_compression_engine(compression_type.clone()).expect("Unable to get compression engine");
// Start with meta plugin types from command line
let mut meta_plugin_types: Vec<crate::meta_plugin::MetaPluginType> = cmd_args_meta_plugin_types(cmd, &args);
debug!("MAIN: Meta plugin types: {:?}", meta_plugin_types);
// Convert digest type to meta plugin type and add to the list if needed
let digest_meta_plugin_type = match digest_type {
crate::meta_plugin::MetaPluginType::DigestSha256 => Some(crate::meta_plugin::MetaPluginType::DigestSha256),
crate::meta_plugin::MetaPluginType::DigestMd5 => Some(crate::meta_plugin::MetaPluginType::DigestMd5),
_ => None,
};
// Add digest meta plugin to the list if needed
if let Some(digest_plugin_type) = digest_meta_plugin_type {
if !meta_plugin_types.contains(&digest_plugin_type) {
meta_plugin_types.push(digest_plugin_type);
}
}
// Initialize meta_plugins with MetaPlugin instances for each MetaPluginType
let mut meta_plugins: Vec<Box<dyn crate::meta_plugin::MetaPlugin>> = meta_plugin_types
.iter()
.map(|meta_plugin_type| crate::meta_plugin::get_meta_plugin(meta_plugin_type.clone()))
.collect();
// Check for unsupported meta plugins, warn the user, and remove them from the list
let mut i = 0;
meta_plugins.retain(|meta_plugin| {
let is_supported = meta_plugin.is_supported();
if !is_supported {
// We need to get the meta name for the warning message
// Since we can't mutably borrow meta_plugin here, we create a temporary one
let meta_plugin_type = meta_plugin_types[i].clone();
let mut temp_plugin = crate::meta_plugin::get_meta_plugin(meta_plugin_type);
eprintln!("Warning: Meta plugin '{}' is enabled but not supported on this system", temp_plugin.meta_name());
}
i += 1;
is_supported
});
(compression_type, compression_engine, meta_plugins)
}
fn create_and_log_item(
conn: &mut rusqlite::Connection,
args: &crate::Args,
tags: &Vec<String>,
compression_type: &crate::compression_engine::CompressionType,
) -> Result<crate::db::Item, anyhow::Error> {
let mut item = crate::db::Item {
id: None,
ts: chrono::Utc::now(),
size: None,
compression: compression_type.to_string(),
};
let id = crate::db::insert_item(conn, item.clone())?;
item.id = Some(id);
debug!("MAIN: Added item {:?}", item.clone());
if !args.options.quiet {
if std::io::stderr().is_terminal() {
let mut t = term::stderr().unwrap();
t.reset().unwrap_or(());
t.attr(term::Attr::Bold).unwrap_or(());
write!(t, "KEEP:").unwrap_or(());
t.reset().unwrap_or(());
write!(t, " New item ").unwrap_or(());
t.attr(term::Attr::Bold).unwrap_or(());
write!(t, "{id}")?;
t.reset().unwrap_or(());
write!(t, " tags: ")?;
t.attr(term::Attr::Bold).unwrap_or(());
write!(t, "{}", tags.join(" "))?;
t.reset().unwrap_or(());
writeln!(t)?;
std::io::stderr().flush()?;
} else {
let mut t = std::io::stderr();
writeln!(t, "KEEP: New item: {} tags: {:?}", id, tags)?;
}
}
Ok(item)
}
fn setup_item_metadata(
conn: &mut rusqlite::Connection,
_args: &crate::Args,
item: &crate::db::Item,
tags: &Vec<String>,
) -> Result<(), anyhow::Error> {
crate::db::set_item_tags(conn, item.clone(), tags)?;
Ok(())
}
fn collect_item_meta(args: &crate::Args) -> std::collections::HashMap<String, String> {
let mut item_meta: std::collections::HashMap<String, String> = crate::modes::common::get_meta_from_env();
if let Ok(hostname) = gethostname::gethostname().into_string() {
if !item_meta.contains_key("hostname") {
item_meta.insert("hostname".to_string(), hostname);
}
}
for item in args.item.meta.iter() {
let item = item.clone();
item_meta.insert(item.key, item.value);
}
item_meta
}
fn process_input_stream(
compression_engine: &Box<dyn crate::compression_engine::CompressionEngine>,
data_path: &std::path::PathBuf,
item_id: i64,
meta_plugins: &mut Vec<Box<dyn crate::meta_plugin::MetaPlugin>>,
) -> Result<(Box<dyn std::io::Write>, crate::db::Item), anyhow::Error> {
let mut item = crate::db::Item {
id: Some(item_id),
ts: chrono::Utc::now(),
size: None,
compression: String::new(), // Will be set later
};
let mut item_path = data_path.clone();
item_path.push(item_id.to_string());
let mut stdin = std::io::stdin().lock();
let mut stdout = std::io::stdout().lock();
let mut buffer = [0; libc::BUFSIZ as usize];
let mut item_out: Box<dyn std::io::Write> =
compression_engine
.create(item_path.clone())
.map_err(|e| anyhow!("Unable to write file {:?}: {}", item_path, e))?;
debug!("MAIN: Starting IO loop");
loop {
let n = stdin.read(&mut buffer[..libc::BUFSIZ as usize])?;
item.size = match item.size {
None => Some(n as i64),
Some(prev_n) => Some(prev_n + n as i64),
};
if n == 0 {
debug!("MAIN: EOF on STDIN");
break;
}
debug!("MAIN: Loop - {:?} bytes", item.size);
stdout.write_all(&buffer[..n])?;
item_out.write_all(&buffer[..n])?;
for meta_plugin in meta_plugins.iter_mut() {
meta_plugin.update(&buffer[..n]);
}
}
debug!("MAIN: Ending IO loop after {:?} bytes", item.size);
stdout.flush()?;
item_out.flush()?;
Ok((item_out, item))
}
fn finalize_meta_plugins(
conn: &rusqlite::Connection,
meta_plugins: &mut Vec<Box<dyn crate::meta_plugin::MetaPlugin>>,
item: &crate::db::Item,
) -> Result<(), anyhow::Error> {
for meta_plugin in meta_plugins.iter_mut() {
let meta_name = meta_plugin.meta_name();
match meta_plugin.finalize() {
Ok(meta_value) => {
let meta = crate::db::Meta {
id: item.id.ok_or_else(|| anyhow!("Item missing ID"))?,
name: meta_name.clone(),
value: meta_value,
};
if let Err(e) = crate::db::store_meta(conn, meta) {
eprintln!("Warning: Failed to store meta value for {}: {}", meta_name, e);
}
}
Err(e) => {
eprintln!("Warning: Failed to finalize meta plugin {}: {}", meta_name, e);
}
}
}
Ok(())
}
/// Main save mode function.
///
/// This function handles the save operation by reading from stdin, duplicating
/// the input to stdout (for real-time display), and saving the content to the
/// item service. It validates arguments, creates the tee reader, and processes
/// the save operation.
///
/// # Arguments
///
/// * `cmd` - Mutable reference to the Clap command for error handling.
/// * `settings` - Application settings containing configuration.
/// * `ids` - Mutable vector of item IDs (should be empty for save mode).
/// * `tags` - Mutable vector of tags to associate with the new item.
/// * `conn` - Mutable reference to the database connection.
/// * `data_path` - Path to the data storage directory.
///
/// # Returns
///
/// * `Result<(), anyhow::Error>` - Success or error if save fails.
///
/// # Examples
///
/// ```ignore
/// // In CLI context, this would be called internally
/// mode_save(&mut cmd, &settings, &mut vec![], &mut vec!["important".to_string()], &mut conn, data_path)?;
/// ```
pub fn mode_save(
cmd: &mut Command,
args: &crate::Args,
ids: &mut Vec<i64>,
settings: &config::Settings,
ids: &mut [i64],
tags: &mut Vec<String>,
conn: &mut rusqlite::Connection,
data_path: std::path::PathBuf,
) -> Result<(), anyhow::Error> {
validate_save_args(cmd, ids);
initialize_tags(tags);
let (compression_type, compression_engine, mut meta_plugins) = setup_compression_and_plugins(cmd, args);
let item_service = ItemService::new(data_path);
let mut item = create_and_log_item(conn, args, tags, &compression_type)?;
setup_item_metadata(conn, args, &item, tags)?; // Pass mutable reference
let stdin = std::io::stdin();
let stdout = std::io::stdout();
// Save as much as possible in case something breaks - don't use transactions
// This allows partial saves to succeed even if some metadata operations fail
let item_meta = collect_item_meta(args);
let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?;
let tee_reader = TeeReader {
reader: stdin.lock(),
writer: stdout.lock(),
};
for kv in item_meta.iter() {
let meta = crate::db::Meta {
id: item_id,
name: kv.0.to_string(),
value: kv.1.to_string(),
};
crate::db::store_meta(conn, meta)?;
}
let (_item_out, processed_item) = process_input_stream(
&compression_engine,
&data_path,
item_id,
&mut meta_plugins,
)?;
item.size = processed_item.size;
item.compression = compression_type.to_string();
finalize_meta_plugins(conn, &mut meta_plugins, &item)?;
crate::db::update_item(conn, item.clone())?;
item_service.save_item(tee_reader, cmd, settings, tags, conn)?;
Ok(())
}

View File

@@ -1,101 +0,0 @@
use anyhow::Result;
use axum::{
routing::get,
Router,
};
use clap::Command;
use log::{debug, info, warn};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_http::cors::CorsLayer;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
use crate::Args;
mod common;
mod status;
mod items;
mod content;
mod docs;
pub use common::{ServerConfig, AppState, logging_middleware};
use status::handle_status;
use items::{handle_list_items, handle_get_item, handle_put_item, handle_delete_item};
use content::{handle_get_content_latest, handle_get_content};
use docs::{handle_openapi, handle_swagger_ui};
pub fn mode_server(
_cmd: &mut Command,
args: &Args,
conn: &mut rusqlite::Connection,
data_path: PathBuf,
) -> Result<()> {
let server_address = args.mode.server.as_ref().unwrap();
let config = ServerConfig {
address: server_address.clone(),
password: args.options.server_password.clone(),
};
// We need to move the connection into the async runtime
let rt = tokio::runtime::Runtime::new()?;
// Take ownership of the connection and move it into the async runtime
let owned_conn = std::mem::replace(conn, rusqlite::Connection::open_in_memory()?);
rt.block_on(run_server(config, owned_conn, data_path, args))
}
async fn run_server(
config: ServerConfig,
conn: rusqlite::Connection,
data_dir: PathBuf,
args: &Args,
) -> Result<()> {
debug!("Starting REST HTTP server on {}", config.address);
// Use the existing database connection
let db_conn = Arc::new(Mutex::new(conn));
let state = AppState {
db: db_conn,
data_dir: data_dir.clone(),
password: config.password.clone(),
args: Arc::new(args.clone()),
};
let app = Router::new()
.route("/status", get(handle_status))
.route("/item/", get(handle_list_items).put(handle_put_item))
.route("/item/:id", get(handle_get_item).delete(handle_delete_item))
.route("/content", get(handle_get_content_latest))
.route("/content/:id", get(handle_get_content))
.route("/openapi.json", get(handle_openapi))
.route("/swagger/", get(handle_swagger_ui))
.layer(axum::middleware::from_fn(logging_middleware))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive())
)
.with_state(state);
let addr: SocketAddr = if config.address.starts_with('/') || config.address.starts_with("./") {
// Unix socket - not supported by axum directly, fall back to TCP
warn!("Unix sockets not yet implemented, falling back to TCP on 127.0.0.1:8080");
"127.0.0.1:8080".parse()?
} else {
config.address.parse()?
};
info!("SERVER: HTTP server listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>()
).await?;
Ok(())
}

View File

@@ -0,0 +1,37 @@
use axum::{
http::{StatusCode, header},
response::Response,
};
use log;
use serde::Serialize;
pub struct ResponseBuilder;
impl ResponseBuilder {
pub fn json<T: Serialize>(data: T) -> Result<Response, StatusCode> {
let json = serde_json::to_vec(&data).map_err(|e| {
log::warn!("Failed to serialize response: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.header(header::CONTENT_LENGTH, json.len().to_string())
.body(axum::body::Body::from(json))
.map_err(|e| {
log::warn!("Failed to build response: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
pub fn binary(content: &[u8], mime_type: &str) -> Result<Response, StatusCode> {
Response::builder()
.header(header::CONTENT_TYPE, mime_type)
.header(header::CONTENT_LENGTH, content.len().to_string())
.body(axum::body::Body::from(content.to_vec()))
.map_err(|e| {
log::warn!("Failed to build response: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
}

1771
src/modes/server/api/item.rs Normal file

File diff suppressed because it is too large Load Diff

102
src/modes/server/api/mod.rs Normal file
View File

@@ -0,0 +1,102 @@
pub mod common;
pub mod item;
pub mod status;
use axum::{
Router,
routing::{delete, get, post},
};
use crate::modes::server::common::AppState;
use utoipa::OpenApi;
#[cfg(feature = "swagger")]
use utoipa_swagger_ui::SwaggerUi;
#[derive(OpenApi)]
#[openapi(
info(
title = "Keep API",
version = "0.1.0",
description = "REST API for Keep - a tool to manage temporary files with automatic compression and metadata generation",
contact(
name = "Keep Project",
)
),
paths(
status::handle_status,
item::handle_list_items,
item::handle_post_item,
item::handle_get_item_latest_meta,
item::handle_get_item_latest_content,
item::handle_get_item_meta,
item::handle_get_item_content,
),
components(
schemas(
crate::modes::server::common::ItemInfo,
crate::modes::server::common::ItemContentInfo,
crate::modes::server::common::ItemInfoListResponse,
crate::modes::server::common::ItemInfoResponse,
crate::modes::server::common::ItemContentInfoResponse,
crate::modes::server::common::MetadataResponse,
crate::modes::server::common::StatusInfoResponse,
crate::common::status::StatusInfo,
crate::modes::server::common::ItemQuery,
crate::modes::server::common::ItemContentQuery,
)
),
tags(
(name = "status", description = "System status and health check endpoints"),
(name = "item", description = "Item management endpoints for storing, retrieving, and managing content with metadata"),
),
servers(
(url = "/", description = "Local server")
)
)]
#[allow(dead_code)]
struct ApiDoc;
pub fn add_routes(router: Router<AppState>) -> Router<AppState> {
router
// Status endpoints
.route("/api/status", get(status::handle_status))
.route("/api/plugins/status", get(status::handle_plugins_status))
// Item endpoints
.route(
"/api/item/",
get(item::handle_list_items).post(item::handle_post_item),
)
.route(
"/api/item/latest/meta",
get(item::handle_get_item_latest_meta),
)
.route(
"/api/item/latest/content",
get(item::handle_get_item_latest_content),
)
.route(
"/api/item/{item_id}/meta",
get(item::handle_get_item_meta).post(item::handle_post_item_meta),
)
.route(
"/api/item/{item_id}/content",
get(item::handle_get_item_content),
)
.route("/api/item/{item_id}", delete(item::handle_delete_item))
.route("/api/item/{item_id}/info", get(item::handle_get_item_info))
.route("/api/item/{item_id}/update", post(item::handle_update_item))
.route("/api/diff", get(item::handle_diff_items))
.route("/api/export", get(item::handle_export_items))
.route("/api/import", post(item::handle_import_items))
}
#[cfg(feature = "swagger")]
pub fn add_docs_routes(router: Router<AppState>) -> Router<AppState> {
router.merge(SwaggerUi::new("/swagger").url("/openapi.json", ApiDoc::openapi()))
}
#[cfg(not(feature = "swagger"))]
pub fn add_docs_routes(router: Router<AppState>) -> Router<AppState> {
router
}

View File

@@ -0,0 +1,129 @@
use axum::{extract::State, http::StatusCode, response::Json};
use crate::modes::server::common::{ApiResponse, AppState, StatusInfoResponse};
async fn generate_status(
state: &AppState,
) -> Result<crate::common::status::StatusInfo, StatusCode> {
let db_path = state
.db
.lock()
.await
.path()
.unwrap_or("unknown")
.to_string();
let status_service = crate::services::status_service::StatusService::new();
let mut cmd = state.cmd.lock().await;
status_service
.generate_status(
&mut cmd,
&state.settings,
state.data_dir.clone(),
db_path.into(),
)
.map_err(|e| {
log::warn!("Failed to generate status: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})
}
#[utoipa::path(
get,
path = "/api/status",
operation_id = "keep_status",
summary = "Get system status",
description = "Retrieve system status including database info, storage paths, compression engines, and metadata plugins.",
responses(
(status = 200, description = "System status retrieved", body = StatusInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearerAuth" = [])
),
tag = "status"
)]
/// Axum handler for the /api/status GET endpoint.
///
/// Generates and returns comprehensive system status using the StatusService.
/// Includes paths, plugins, compression info, and configuration details.
///
/// # Arguments
///
/// * `State(state)` - The shared AppState containing settings, DB, and paths.
///
/// # Returns
///
/// * `Ok(Json<StatusInfoResponse>)` - Success response with status data.
/// * `Err(StatusCode)` - HTTP error status (e.g., 500 for internal errors; 401 if auth fails elsewhere).
///
/// # Errors
///
/// Returns StatusCode::INTERNAL_SERVER_ERROR if status generation panics or fails (current impl assumes success).
/// Auth errors are handled by middleware before reaching this handler.
///
/// # Examples
///
/// ```ignore
/// // In an Axum app:
/// async fn app() -> Result<Json<StatusInfoResponse>, StatusCode> {
/// handle_status(State(app_state)).await
/// }
/// ```
pub async fn handle_status(
State(state): State<AppState>,
) -> Result<Json<StatusInfoResponse>, StatusCode> {
let status_info = generate_status(&state).await?;
let response = StatusInfoResponse {
success: true,
data: Some(status_info),
error: None,
};
Ok(Json(response))
}
#[derive(Debug, serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
pub struct PluginsStatusResponse {
pub meta_plugins: std::collections::HashMap<String, crate::common::status::MetaPluginInfo>,
pub filter_plugins: Vec<crate::common::status::FilterPluginInfo>,
pub compression: Vec<crate::common::status::CompressionInfo>,
}
#[utoipa::path(
get,
path = "/api/plugins/status",
operation_id = "keep_plugins_status",
summary = "Get plugins status",
description = "Retrieve detailed status of all available plugins including meta, filter, and compression plugins.",
responses(
(status = 200, description = "Plugins status retrieved", body = ApiResponse<PluginsStatusResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearerAuth" = [])
),
tag = "status"
)]
pub async fn handle_plugins_status(
State(state): State<AppState>,
) -> Result<Json<crate::modes::server::common::ApiResponse<PluginsStatusResponse>>, StatusCode> {
let status_info = generate_status(&state).await?;
let response_data = PluginsStatusResponse {
meta_plugins: status_info.meta_plugins,
filter_plugins: status_info.filter_plugins,
compression: status_info.compression,
};
let response = crate::modes::server::common::ApiResponse::<PluginsStatusResponse> {
success: true,
data: Some(response_data),
error: None,
};
Ok(Json(response))
}

118
src/modes/server/auth.rs Normal file
View File

@@ -0,0 +1,118 @@
use axum::http::Method;
use jsonwebtoken::{DecodingKey, TokenData, Validation, decode};
use log::debug;
use serde::Deserialize;
/// JWT claims for permission-based access control.
///
/// External token generators should include these claims in the JWT payload.
/// The server validates the signature and checks permissions for each request.
///
/// # Example token payload
///
/// ```json
/// {
/// "sub": "my-client",
/// "exp": 1735689600,
/// "read": true,
/// "write": true,
/// "delete": false
/// }
/// ```
#[derive(Debug, Deserialize)]
pub struct Claims {
/// Subject (client identifier).
pub sub: String,
/// Expiration time (Unix timestamp).
pub exp: usize,
/// Read permission (GET requests).
#[serde(default)]
pub read: bool,
/// Write permission (POST/PUT requests).
#[serde(default)]
pub write: bool,
/// Delete permission (DELETE requests).
#[serde(default)]
pub delete: bool,
}
/// Returns the required permission for an HTTP method.
///
/// # Mapping
///
/// - GET, HEAD → "read"
/// - POST, PUT, PATCH → "write"
/// - DELETE → "delete"
///
/// # Arguments
///
/// * `method` - The HTTP method of the incoming request.
///
/// # Returns
///
/// A string slice representing the required permission.
pub fn required_permission(method: &Method) -> &'static str {
if method == Method::GET || method == Method::HEAD {
"read"
} else if method == Method::DELETE {
"delete"
} else {
"write"
}
}
/// Checks if the JWT claims grant the required permission.
///
/// # Arguments
///
/// * `claims` - The validated JWT claims.
/// * `permission` - The required permission string ("read", "write", or "delete").
///
/// # Returns
///
/// `true` if the claims grant the permission, `false` otherwise.
pub fn check_permission(claims: &Claims, permission: &str) -> bool {
match permission {
"read" => claims.read,
"write" => claims.write,
"delete" => claims.delete,
_ => false,
}
}
/// Validates a JWT token and returns the claims.
///
/// Uses HMAC-SHA256 signature verification with the provided secret.
///
/// # Arguments
///
/// * `token` - The JWT token string (without "Bearer " prefix).
/// * `secret` - The secret key used to verify the signature.
///
/// # Returns
///
/// * `Ok(Claims)` - The validated claims if the token is valid.
/// * `Err(String)` - A human-readable error message if validation fails.
pub fn validate_jwt(token: &str, secret: &str) -> Result<Claims, String> {
let mut validation = Validation::new(jsonwebtoken::Algorithm::HS256);
validation.algorithms = vec![jsonwebtoken::Algorithm::HS256];
validation.set_required_spec_claims(&["exp", "sub"]);
let token_data: TokenData<Claims> = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.map_err(|e| {
debug!("JWT validation failed: {e}");
match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => "Token expired".to_string(),
jsonwebtoken::errors::ErrorKind::InvalidSignature => "Invalid token".to_string(),
jsonwebtoken::errors::ErrorKind::InvalidToken => "Malformed token".to_string(),
jsonwebtoken::errors::ErrorKind::ImmatureSignature => "Token not yet valid".to_string(),
_ => "Invalid token".to_string(),
}
})?;
Ok(token_data.claims)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,181 +0,0 @@
use anyhow::{Result, anyhow};
use axum::{
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode},
response::Json,
};
use log::warn;
use serde_json::json;
use std::collections::HashMap;
use std::io::Read;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::str::FromStr;
use crate::compression_engine::{CompressionType, get_compression_engine};
use crate::db;
use super::common::{AppState, ApiResponse, TagsQuery, check_auth};
pub async fn handle_get_content_latest(
State(state): State<AppState>,
Query(params): Query<TagsQuery>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<ApiResponse<String>>, StatusCode> {
if !check_auth(&headers, &state.password) {
warn!("Unauthorized request to /content from {}", addr);
return Err(StatusCode::UNAUTHORIZED);
}
let mut conn = state.db.lock().await;
let item = if let Some(tags_str) = params.tags {
let tags: Vec<String> = tags_str.split(',').map(|t| t.trim().to_string()).collect();
db::get_item_matching(&mut *conn, &tags, &HashMap::new())
.map_err(|e| {
warn!("Failed to get item matching tags {:?} for content: {}", tags, e);
StatusCode::INTERNAL_SERVER_ERROR
})?
} else {
db::get_item_last(&mut *conn).map_err(|e| {
warn!("Failed to get last item for content: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?
};
if let Some(item) = item {
match get_item_content(&item, &state.data_dir).await {
Ok(content) => {
let response = ApiResponse {
success: true,
data: Some(content),
error: None,
};
Ok(Json(response))
}
Err(e) => {
warn!("Failed to get content for item {}: {}", item.id.unwrap_or(0), e);
let response = ApiResponse::<String> {
success: false,
data: None,
error: Some(format!("Failed to retrieve content: {}", e)),
};
Ok(Json(response))
}
}
} else {
Err(StatusCode::NOT_FOUND)
}
}
pub async fn handle_get_content(
State(state): State<AppState>,
Path(item_id): Path<String>,
headers: HeaderMap,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
) -> Result<Json<ApiResponse<String>>, StatusCode> {
if !check_auth(&headers, &state.password) {
warn!("Unauthorized request to /content/{} from {}", item_id, addr);
return Err(StatusCode::UNAUTHORIZED);
}
if let Ok(id) = item_id.parse::<i64>() {
// Validate that item ID is positive to prevent path traversal issues
if id <= 0 {
warn!("Invalid item ID {} from {}", id, addr);
return Err(StatusCode::BAD_REQUEST);
}
let mut conn = state.db.lock().await;
if let Some(item) = db::get_item(&mut *conn, id).map_err(|e| {
warn!("Failed to get item {} for content: {}", id, e);
StatusCode::INTERNAL_SERVER_ERROR
})? {
match get_item_content(&item, &state.data_dir).await {
Ok(content) => {
let response = ApiResponse {
success: true,
data: Some(content),
error: None,
};
Ok(Json(response))
}
Err(e) => {
warn!("Failed to get content for item {}: {}", id, e);
let response = ApiResponse::<String> {
success: false,
data: None,
error: Some(format!("Failed to retrieve content: {}", e)),
};
Ok(Json(response))
}
}
} else {
Err(StatusCode::NOT_FOUND)
}
} else {
Err(StatusCode::BAD_REQUEST)
}
}
async fn get_item_content(item: &db::Item, data_dir: &PathBuf) -> Result<String> {
let item_id = item.id.ok_or_else(|| anyhow!("Item missing ID"))?;
// Validate that item ID is positive to prevent path traversal issues
if item_id <= 0 {
return Err(anyhow!("Invalid item ID: {}", item_id));
}
let mut item_path = data_dir.clone();
item_path.push(item_id.to_string());
let compression_type = CompressionType::from_str(&item.compression)?;
let compression_engine = get_compression_engine(compression_type)?;
// Read the content using the compression engine
let mut reader = compression_engine.open(item_path)?;
let mut content = String::new();
reader.read_to_string(&mut content)?;
Ok(content)
}
pub fn get_content_openapi_spec() -> serde_json::Value {
json!({
"/content": {
"get": {
"summary": "Get content of latest item",
"parameters": [
{
"name": "tags",
"in": "query",
"schema": {"type": "string"},
"description": "Comma-separated list of tags to filter by"
}
],
"responses": {
"200": {"description": "Item content"},
"404": {"description": "No items found"}
}
}
},
"/content/{id}": {
"get": {
"summary": "Get content by item ID",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {"type": "integer"}
}
],
"responses": {
"200": {"description": "Item content"},
"404": {"description": "Item not found"}
}
}
}
})
}

View File

@@ -1,110 +0,0 @@
use axum::response::{Html, Json};
use serde_json::json;
use super::status::get_status_openapi_spec;
use super::items::get_items_openapi_spec;
use super::content::get_content_openapi_spec;
pub async fn handle_openapi() -> Json<serde_json::Value> {
let mut paths = json!({});
// Merge all endpoint specifications
let status_paths = get_status_openapi_spec();
let items_paths = get_items_openapi_spec();
let content_paths = get_content_openapi_spec();
// Merge the path objects
if let serde_json::Value::Object(ref mut paths_map) = paths {
if let serde_json::Value::Object(status_map) = status_paths {
for (key, value) in status_map {
paths_map.insert(key, value);
}
}
if let serde_json::Value::Object(items_map) = items_paths {
for (key, value) in items_map {
paths_map.insert(key, value);
}
}
if let serde_json::Value::Object(content_map) = content_paths {
for (key, value) in content_map {
paths_map.insert(key, value);
}
}
}
let openapi_spec = json!({
"openapi": "3.0.0",
"info": {
"title": "Keep API",
"version": "1.0.0",
"description": "REST API for the Keep data storage system"
},
"servers": [
{
"url": "/",
"description": "Local server"
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {
"ItemInfo": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"ts": {"type": "string", "format": "date-time"},
"size": {"type": "integer", "nullable": true},
"compression": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"metadata": {"type": "object"}
}
},
"StatusInfo": {
"type": "object",
"properties": {
"version": {"type": "string"},
"database_path": {"type": "string"},
"data_directory": {"type": "string"},
"compression_engines": {"type": "array", "items": {"type": "string"}},
"meta_plugins": {"type": "array", "items": {"type": "string"}}
}
}
}
},
"security": [{"bearerAuth": []}],
"paths": paths
});
Json(openapi_spec)
}
pub async fn handle_swagger_ui() -> Html<&'static str> {
let html = r#"<!DOCTYPE html>
<html>
<head>
<title>Keep API Documentation</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3.52.5/swagger-ui.css" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@3.52.5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: '/openapi.json',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.presets.standalone
]
});
</script>
</body>
</html>"#;
Html(html)
}

Some files were not shown because too many files have changed in this diff Show More