Skip to content

Commit 7459727

Browse files
committed
refactor: json fmt mod
This also addresses feedback from json PR: - mode is output as a string, using the octal representation - path uses the same format as the ripgrep output - use "size_bytes" instead of "size" to make the unit more clear Also, I fixed an issue where the mode included high bytes that are actually used to encode the filetype (at least on Linux).
1 parent edd9729 commit 7459727

10 files changed

Lines changed: 477 additions & 465 deletions

File tree

doc/fd.1

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -513,25 +513,29 @@ due to OS restrictions on the maximum length of command lines.
513513
.TP
514514
.BI "\-\-json "
515515
.RS
516-
Specify JSONL (as known as NDJSON) format to use for the output.
516+
Specify JSONL (as known as NDJSON) format to use for the output.
517517

518518
Output fields:
519519

520-
- "path": The file path as a UTF\-8 string.
520+
- "path": An object containing the path of the file. When the path is valid UTF-8, it this contains a single "text" field
521+
containing the path as a string. Otherwise it contains a single "bytes" field containing the base64 encoded bytes of the
522+
path.
521523

522-
Note that when the path contains invalid UTF-8 sequences, it is encoded in base64 and stored in the "path_b64" field instead.
524+
On windows, this may use a lossy UTF-8 encoding, since there isn't an obvious way to encode the pathname.
523525

524-
- "type": The file type (e.g., "file", "directory").
526+
If a custom path separator is given, it is used in the "text" field, but not in the "bytes" field.
525527

526-
- "size": The file size in bytes.
528+
- "type": The file type (e.g., "file", "directory", "symlink").
529+
530+
- "size_bytes": The file size in bytes.
527531

528532
- "mode": The file permissions in octal (e.g., 644).
529533

530-
- "modified": The last modification time in ISO 8601 format (e.g., 2000-01-01T12:00:00Z).
534+
- "modified": The last modification time in RFC3339 (ISO 8601) format (e.g., 2000-01-01T12:00:00Z).
531535

532-
- "accessed": The last access time in ISO 8601 format.
536+
- "accessed": The last access time in RFC3339 format.
533537

534-
- "created": The creation time in ISO 8601 format.
538+
- "created": The creation time in RFC3339 format.
535539
.RE
536540
.TP
537541
.SH PATTERN SYNTAX

src/cli.rs

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ pub struct Opts {
653653
long,
654654
value_name = "json",
655655
help = "Print results in JSONL format so you can pipe it to tools.",
656+
conflicts_with_all(&["format", "list_details"]),
656657
long_help
657658
)]
658659
pub json: bool,
@@ -834,15 +835,6 @@ pub enum HyperlinkWhen {
834835
Never,
835836
}
836837

837-
#[derive(Copy, Clone, PartialEq, Eq, Debug, ValueEnum)]
838-
pub enum OutputFormat {
839-
/// Plain text output (default)
840-
Plain,
841-
/// JSONL (JSON Lines, as known as Newline Delimited JSON) output
842-
#[value(alias = "ndjson")]
843-
Jsonl,
844-
}
845-
846838
// there isn't a derive api for getting grouped values yet,
847839
// so we have to use hand-rolled parsing for exec and exec-batch
848840
pub struct Exec {

src/config.rs

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
use std::{path::PathBuf, sync::Arc, time::Duration};
22

3-
use lscolors::LsColors;
43
use regex::bytes::RegexSet;
54

65
use crate::exec::CommandSet;
76
use crate::filetypes::FileTypes;
87
#[cfg(unix)]
98
use crate::filter::OwnerFilter;
109
use crate::filter::{SizeFilter, TimeFilter};
11-
use crate::fmt::FormatTemplate;
10+
use crate::fmt::OutputFormat;
1211

1312
/// Configuration options for *fd*.
1413
pub struct Config {
@@ -70,10 +69,6 @@ pub struct Config {
7069
/// `max_buffer_time`.
7170
pub max_buffer_time: Option<Duration>,
7271

73-
/// `None` if the output should not be colorized. Otherwise, a `LsColors` instance that defines
74-
/// how to style different filetypes.
75-
pub ls_colors: Option<LsColors>,
76-
7772
/// Whether or not we are writing to an interactive terminal
7873
#[cfg_attr(not(unix), allow(unused))]
7974
pub interactive_terminal: bool,
@@ -87,8 +82,10 @@ pub struct Config {
8782
/// The value (if present) will be a lowercase string without leading dots.
8883
pub extensions: Option<RegexSet>,
8984

90-
/// A format string to use to format results, similarly to exec
91-
pub format: Option<FormatTemplate>,
85+
/// The format to use for the output
86+
///
87+
/// determined by multiple options
88+
pub format: OutputFormat,
9289

9390
/// If a value is supplied, each item found will be used to generate and execute commands.
9491
pub command: Option<Arc<CommandSet>>,
@@ -130,9 +127,6 @@ pub struct Config {
130127

131128
/// Whether or not to use hyperlinks on paths
132129
pub hyperlink: bool,
133-
134-
/// Whether to print results in JSONL format
135-
pub jsonl: bool,
136130
}
137131

138132
impl Config {

src/fmt/json.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
use std::borrow::Cow;
2+
use std::fs::{FileType, Metadata};
3+
use std::io::Write;
4+
#[cfg(unix)]
5+
use std::os::unix::{ffi::OsStrExt, fs::MetadataExt};
6+
use std::path::{MAIN_SEPARATOR, Path};
7+
use std::time::SystemTime;
8+
9+
use base64::{Engine as _, prelude::BASE64_STANDARD};
10+
use jiff::Timestamp;
11+
12+
pub fn output_json<W: Write>(
13+
out: &mut W,
14+
path: &Path,
15+
filetype: Option<FileType>,
16+
metadata: Option<&Metadata>,
17+
path_separator: &Option<String>,
18+
) -> std::io::Result<()> {
19+
out.write_all(b"{")?;
20+
21+
// Print the path as an object that either has a "text" key containing the
22+
// utf8 path, or a "bytes" key with the base64 encoded bytes of the path
23+
#[cfg(unix)]
24+
match path.to_str() {
25+
Some(text) => {
26+
let final_path: Cow<str> = if let Some(sep) = path_separator {
27+
text.replace(MAIN_SEPARATOR, sep).into()
28+
} else {
29+
text.into()
30+
};
31+
// NB: This assumes that rust's debug output for a string
32+
// is a valid JSON string. At time of writing this is the case
33+
// but it is possible, though unlikely, that this could change
34+
// in the future.
35+
write!(out, r#""path":{{"text":{:?}}}"#, final_path)?;
36+
}
37+
None => {
38+
let encoded_bytes = BASE64_STANDARD.encode(path.as_os_str().as_bytes());
39+
write!(out, r#""path":{{"bytes":"{}"}}"#, encoded_bytes)?;
40+
}
41+
};
42+
// On non-unix platforms, if the path isn't valid utf-8,
43+
// we don't know what kind of encoding was used, and
44+
// as_encoded_bytes() isn't necessarily stable between rust versions
45+
// so the best we can really do is a lossy string
46+
#[cfg(not(unix))]
47+
write!(out, r#""path":{{"text":{:?}}}"#, path.to_string_lossy())?;
48+
49+
// print the type of file
50+
let ft = match filetype {
51+
Some(ft) if ft.is_dir() => "directory",
52+
Some(ft) if ft.is_file() => "file",
53+
Some(ft) if ft.is_symlink() => "symlink",
54+
_ => "unknown",
55+
};
56+
write!(out, r#","type":"{}""#, ft)?;
57+
58+
if let Some(meta) = metadata {
59+
// Output the mode as octal
60+
// We also need to mask it to just include the permission
61+
// bits and not the file type bits (that is handled by "type" above)
62+
#[cfg(unix)]
63+
write!(out, r#","mode":"{:o}""#, meta.mode() & 0x7777)?;
64+
65+
write!(out, r#","size_bytes":{}"#, meta.len())?;
66+
67+
// would it be better to do these with os-specific functions?
68+
if let Ok(modified) = meta.modified().map(json_timestamp) {
69+
write!(out, r#","modified":"{}""#, modified)?;
70+
}
71+
if let Ok(accessed) = meta.accessed().map(json_timestamp) {
72+
write!(out, r#","modified":"{}""#, accessed)?;
73+
}
74+
if let Ok(created) = meta.created().map(json_timestamp) {
75+
write!(out, r#","modified":"{}""#, created)?;
76+
}
77+
}
78+
79+
out.write_all(b"}")
80+
}
81+
82+
fn json_timestamp(time: SystemTime) -> Timestamp {
83+
// System timestamps should always be valid, so assume that we can
84+
// unwrap it
85+
// If we ever do want to handle an error here, maybe convert to either the MAX or MIN
86+
// timestamp depending on which side of the epoch the SystemTime is?
87+
Timestamp::try_from(time).expect("Invalid timestamp")
88+
}

0 commit comments

Comments
 (0)