Skip to content

Commit 7def9bc

Browse files
authored
Add class field discovery for computed selectors (#77)
## Summary - Make `class fields --name <class>` the canonical field-inventory command. - Include enabled shared and personal computed selectors with source, observed count, types, and examples. - Retain `object fields --class <class>` as a deprecated compatibility alias that prints an exact, argument-preserving replacement. - Add reusable command-deprecation metadata for future command-path and option migrations. ## Rationale and design decisions Field discovery belongs to the class being described, so the canonical command moves under `class`. The shipped `object fields` spelling remains registered to avoid an abrupt command removal. Deprecation behavior lives in the shared command builder rather than the individual field handler. A deprecated command declares its replacement path and option renames; the common runner then annotates help and emits a shell-safe replacement that preserves scoped invocations, options, positionals, pipelines, and the `--` boundary. Computed definitions remain visible when enabled even if no sampled object produces a value. Counts, types, and examples are observation-based, while computed evaluation errors do not hide the definition row. ## Behavior notes `class fields --name <class>` samples the class data paths and lists enabled shared selectors as `S:<key>` and enabled personal selectors as `P:<key>`. A `Source` column distinguishes data, shared-computed, and personal-computed rows. The deprecated alias emits an exact replacement such as `class fields --name Hosts`, including rewritten `--class` or `-c` arguments and any trailing pipeline. ## User and compatibility impact Existing field-inventory output has a breaking output-shape change: it gains a `Source` column and computed-selector rows. Structured consumers that validate the exact legacy column set or assume every row is a `data.*` path must accept the new column and either handle or filter the `S:` and `P:` rows. No command is removed: `object fields --class <class>` continues to work. Users should migrate to `class fields --name <class>` using the exact replacement printed by the deprecation warning. There are no other breaking changes. ## Declared compatibility surface The declared Hubuum server target remains 0.0.9, and `COMPATIBILITY.md` is unchanged. The OpenAPI surface and all request and response types are unchanged; the implementation composes existing typed gateway operations for computed definitions and computed object values. Feature availability is unchanged, no MSRV is declared or changed, and dependencies and `Cargo.lock` are unchanged. The root manifest retains its exact `hubuum_client` 0.9.1 pin. No public workspace-crate API changes, so no Rust library semver migration is required. This change makes no new cross-version server compatibility claim. The existing declared target remains backed by the immutable Hubuum v0.0.9 image recorded in `COMPATIBILITY.md` (`sha256:1f12baf882b6d3df5b4b2dbdf26aad0793274e57f86a2c186b8e1e68632db5db`). ## Changelog `CHANGELOG.md` documents the canonical command, compatibility alias, exact migration, reusable warning behavior, computed-selector inventory, and breaking output-shape change under `[Unreleased]`.
1 parent e7415c6 commit 7def9bc

8 files changed

Lines changed: 806 additions & 226 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
## [Unreleased]
44

5+
- Added canonical `class fields --name <class>` field discovery, with
6+
`object fields --class <class>` retained as a deprecated compatibility alias
7+
that prints an exact replacement command. Existing invocations continue to
8+
work; users should migrate to `class fields --name <class>`. Reusable command
9+
deprecation metadata now annotates help and rewrites renamed options in
10+
warnings. The inventory includes enabled shared and personal computed
11+
selectors alongside sampled `data.*` paths, identifies each field's source,
12+
and summarizes computed values observed in the same object sample. This is a
13+
breaking output-shape change: the inventory gains a `Source` column and
14+
computed-selector rows. Structured consumers that assumed an exact column set
15+
or data-only rows must accept the new column and rows.
516
- Added extension packs under `extension <pack>`, with dependency-free portable
617
JSONC/JQ workflow packs as the preferred kind and a versioned argv/JSON
718
protocol for explicitly executable packs. Packs have deterministic system and

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,20 @@ hubuum-cli object list --class Hosts --computed all --output json
236236

237237
In the REPL, data-field completion merges the selected class's JSON Schema with
238238
a sample of up to 100 objects, using the same depth-six traversal as
239-
`object fields`. This supplies escaped JSON Pointers for computed `--path`
239+
`class fields`. This supplies escaped JSON Pointers for computed `--path`
240240
options and dotted paths for aggregate dimensions, measures, and filters.
241241
Inspected fields are cached for `cache.time` seconds (one hour by default) and
242242
the cache can be bypassed with `cache.disable`.
243243

244+
`class fields --name <class>` is also the field inventory for downstream
245+
selectors. Alongside sampled `data.*` paths, it lists enabled shared and
246+
personal computed fields as `S:<key>` and `P:<key>`. The `Source` column
247+
distinguishes the three kinds; counts, types, and examples are observed from the
248+
same object sample, so a computed definition with no sampled value still
249+
appears with an empty observation. The former `object fields --class <class>`
250+
spelling remains available as a deprecated compatibility alias and prints an
251+
exact replacement command when invoked.
252+
244253
Without per-class configuration, computed values are off by default. Use repeatable, dynamically completed
245254
`--computed S:<key>` and `--computed P:<key>` options to select individual
246255
shared or personal fields, or `--computed all` to select every field:

docs/manual-test.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ object modify --class SmokeHost smoke-1 --description "Smoke object updated" --d
7373
object data patch --class SmokeHost --name smoke-1 --patch '[{"op":"add","path":"/facts","value":{"distribution":"RHEL","rhel_subscription":"active"}}]'
7474
object data patch --class SmokeHost --name smoke-1 --patch '[{"op":"add","path":"/facts","value":{"distribution":"Fedora"}}]'
7575
object data patch --class SmokeHost --name smoke-created --patch '[{"op":"add","path":"/facts","value":{"distribution":"Fedora"}}]' --create --description "Created by CLI smoke test"
76-
object fields --class SmokeHost
76+
class fields --name SmokeHost
7777
```
7878

7979
Run server-side object aggregates, including grouping, global numeric measures,
@@ -95,6 +95,7 @@ computed shared preview --class SmokeHost --key owner_copy --label "Owner copy"
9595
computed shared rebuild --class SmokeHost
9696
computed personal create --class SmokeHost --key owner_personal --label "Personal owner" --operation first_non_null --path /owner --result-type string
9797
computed personal list --class SmokeHost
98+
class fields --name SmokeHost
9899
object aggregate --class SmokeHost --group-by S:owner_copy --where S:owner_copy equals platform --output json
99100
object show --class SmokeHost smoke-1 --computed S:owner_copy
100101
object list --class SmokeHost --computed all --output json
@@ -160,6 +161,11 @@ Expected results:
160161
explicit `--computed` values replace them and `--computed none` suppresses them.
161162
- Computed list text uses `S:<key>` and `P:<key>` columns rather than a single
162163
truncated computed-data preview.
164+
- `class fields` includes enabled `S:<key>` and `P:<key>` selectors after the
165+
sampled `data.*` paths, identifies their source, and reports values observed
166+
in the same sample. `object fields --class SmokeHost` produces the same
167+
inventory as a compatibility alias and warns to use the exact replacement
168+
`class fields --name SmokeHost` command.
163169
- `S:<key>` and `P:<key>` sorts order the full matching set before `--limit`;
164170
combining either with `--cursor` returns an actionable error.
165171
- Display aliases use the first selector that exists and can be selected like

src/catalog.rs

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,6 +1348,46 @@ mod tests {
13481348
);
13491349
}
13501350

1351+
#[test]
1352+
fn class_fields_is_canonical_and_object_fields_remains_compatible() {
1353+
let catalog = build_command_catalog();
1354+
let class_fields = catalog
1355+
.resolve_command(&[], &["class".to_string(), "fields".to_string()])
1356+
.expect("class fields command");
1357+
let object_fields = catalog
1358+
.resolve_command(&[], &["object".to_string(), "fields".to_string()])
1359+
.expect("object fields compatibility command");
1360+
1361+
assert_eq!(
1362+
class_fields.command.workflow_contract().command_id(),
1363+
"class fields"
1364+
);
1365+
assert!(class_fields
1366+
.command
1367+
.workflow_contract()
1368+
.input("name")
1369+
.is_some());
1370+
assert_eq!(
1371+
object_fields.command.workflow_contract().command_id(),
1372+
"object fields"
1373+
);
1374+
assert!(object_fields
1375+
.command
1376+
.workflow_contract()
1377+
.input("class")
1378+
.is_some());
1379+
assert!(object_fields
1380+
.command
1381+
.about
1382+
.as_deref()
1383+
.is_some_and(|about| about.ends_with("(deprecated)")));
1384+
assert!(object_fields
1385+
.command
1386+
.long_about
1387+
.as_deref()
1388+
.is_some_and(|about| about.contains("use 'class fields' instead")));
1389+
}
1390+
13511391
#[test]
13521392
fn render_command_help_includes_option_metadata() {
13531393
let mut builder = CommandCatalogBuilder::new();
@@ -1472,9 +1512,13 @@ mod tests {
14721512
.expect("collection scope");
14731513

14741514
assert!(plain.contains("class"));
1475-
assert!(plain.contains("create, delete, list, modify, show"));
14761515
assert!(plain.contains("object"));
1477-
assert!(plain.contains("create, delete, list, modify, show"));
1516+
assert_eq!(
1517+
plain
1518+
.matches("create, delete, fields, list, modify, show")
1519+
.count(),
1520+
2
1521+
);
14781522
assert!(plain.contains("event"));
14791523
assert!(plain.contains("delivery, sink, subscription"));
14801524
assert!(!plain.contains("event-subscription"));

src/commands/builder.rs

Lines changed: 187 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ use crate::catalog::{
77
AsyncCommandHandler, CommandCatalog, CommandCatalogBuilder, CommandContext, CommandInvocation,
88
CommandOutcome, CommandSpec, CompletionSpec, OptionSpec, ScopeAction,
99
};
10+
use crate::command_line::shell_escape;
1011
use crate::commands::{self, command_options, render_format, table_headers, CliCommand};
1112
use crate::config::get_config;
1213
use crate::errors::AppError;
1314
use crate::extensions::{ExtensionRegistry, WorkflowProgram};
1415
use crate::output::{
15-
reset_output, set_pipeline, set_pipeline_suffix, set_render_format, set_table_headers,
16-
take_output,
16+
add_warning, reset_output, set_pipeline, set_pipeline_suffix, set_render_format,
17+
set_table_headers, take_output,
1718
};
1819
use crate::tokenizer::CommandTokenizer;
1920

@@ -27,6 +28,88 @@ pub(crate) struct CommandDocs {
2728
pub examples: Option<&'static str>,
2829
}
2930

31+
#[derive(Clone, Copy)]
32+
pub(crate) struct CommandDeprecation {
33+
replacement_path: &'static [&'static str],
34+
option_renames: &'static [(&'static str, &'static str)],
35+
}
36+
37+
impl CommandDeprecation {
38+
pub(crate) const fn renamed(
39+
replacement_path: &'static [&'static str],
40+
option_renames: &'static [(&'static str, &'static str)],
41+
) -> Self {
42+
Self {
43+
replacement_path,
44+
option_renames,
45+
}
46+
}
47+
48+
fn replacement_command(
49+
&self,
50+
tokens: &CommandTokenizer,
51+
command_index: usize,
52+
pipeline_suffix: Option<&str>,
53+
) -> String {
54+
let command = self
55+
.replacement_path
56+
.iter()
57+
.map(|token| (*token).to_string())
58+
.chain(
59+
tokens
60+
.raw_tokens()
61+
.iter()
62+
.skip(command_index.saturating_add(1))
63+
.scan(false, |options_ended, token| {
64+
if token == "--" {
65+
*options_ended = true;
66+
}
67+
let token = if *options_ended {
68+
token.to_string()
69+
} else {
70+
self.rename_option(token)
71+
};
72+
Some(shell_escape(&token))
73+
}),
74+
)
75+
.collect::<Vec<_>>()
76+
.join(" ");
77+
match pipeline_suffix {
78+
Some(suffix) => format!("{command} {suffix}"),
79+
None => command,
80+
}
81+
}
82+
83+
fn rename_option(&self, token: &str) -> String {
84+
for (old, new) in self.option_renames {
85+
if token == *old {
86+
return (*new).to_string();
87+
}
88+
if let Some(value) = token
89+
.strip_prefix(old)
90+
.and_then(|value| value.strip_prefix('='))
91+
{
92+
return format!("{new}={value}");
93+
}
94+
}
95+
token.to_string()
96+
}
97+
98+
fn help_notice(&self) -> String {
99+
format!(
100+
"Deprecated: use '{}' instead. Invocations print an exact replacement command.",
101+
self.replacement_path.join(" ")
102+
)
103+
}
104+
105+
fn warning(&self, command_path: &[String], replacement: &str) -> String {
106+
format!(
107+
"Command '{}' is deprecated; use `{replacement}` instead.",
108+
command_path.join(" ")
109+
)
110+
}
111+
}
112+
30113
pub fn build_command_catalog() -> CommandCatalog {
31114
let mut builder = CommandCatalogBuilder::new();
32115
let mut extensions = ExtensionRegistry::discover(&get_config());
@@ -38,6 +121,7 @@ pub fn build_command_catalog() -> CommandCatalog {
38121
commands::auth::register_commands(&mut builder);
39122
commands::jobs::register_commands(&mut builder);
40123
commands::class::register_commands(&mut builder);
124+
commands::class_fields::register_commands(&mut builder);
41125
commands::config::register_commands(&mut builder);
42126
commands::collection::register_commands(&mut builder);
43127
commands::computed::register_commands(&mut builder);
@@ -72,6 +156,30 @@ pub fn build_command_catalog() -> CommandCatalog {
72156
}
73157

74158
pub(crate) fn catalog_command<C>(name: &str, command: C, docs: CommandDocs) -> CommandSpec
159+
where
160+
C: CliCommand + Clone + 'static,
161+
{
162+
catalog_command_with_deprecation(name, command, docs, None)
163+
}
164+
165+
pub(crate) fn deprecated_catalog_command<C>(
166+
name: &str,
167+
command: C,
168+
docs: CommandDocs,
169+
deprecation: CommandDeprecation,
170+
) -> CommandSpec
171+
where
172+
C: CliCommand + Clone + 'static,
173+
{
174+
catalog_command_with_deprecation(name, command, docs, Some(deprecation))
175+
}
176+
177+
fn catalog_command_with_deprecation<C>(
178+
name: &str,
179+
command: C,
180+
docs: CommandDocs,
181+
deprecation: Option<CommandDeprecation>,
182+
) -> CommandSpec
75183
where
76184
C: CliCommand + Clone + 'static,
77185
{
@@ -104,10 +212,21 @@ where
104212
C::EFFECTS,
105213
Arc::new(CommandHandler {
106214
command: Arc::new(command),
215+
deprecation,
107216
}) as Arc<dyn AsyncCommandHandler>,
108217
);
109-
spec.about = docs.about.map(str::to_string);
110-
spec.long_about = docs.long_about.map(str::to_string);
218+
spec.about = docs.about.map(|about| match deprecation {
219+
Some(_) => format!("{about} (deprecated)"),
220+
None => about.to_string(),
221+
});
222+
spec.long_about = match (docs.long_about, deprecation) {
223+
(Some(long_about), Some(deprecation)) => {
224+
Some(format!("{long_about}\n\n{}", deprecation.help_notice()))
225+
}
226+
(Some(long_about), None) => Some(long_about.to_string()),
227+
(None, Some(deprecation)) => Some(deprecation.help_notice()),
228+
(None, None) => None,
229+
};
111230
spec.examples = docs.examples.map(str::to_string);
112231
spec
113232
}
@@ -117,6 +236,7 @@ where
117236
C: CliCommand + Clone + 'static,
118237
{
119238
command: Arc<C>,
239+
deprecation: Option<CommandDeprecation>,
120240
}
121241

122242
#[async_trait]
@@ -137,6 +257,7 @@ where
137257
})?;
138258
let raw_line = invocation.raw_line.clone();
139259
let pipeline = invocation.pipeline.clone();
260+
let deprecation = self.deprecation;
140261

141262
spawn_blocking(move || {
142263
reset_output()?;
@@ -147,6 +268,14 @@ where
147268
invocation.command_index,
148269
&command_options::<C>(),
149270
)?;
271+
if let Some(deprecation) = deprecation {
272+
let replacement = deprecation.replacement_command(
273+
&tokens,
274+
invocation.command_index,
275+
invocation.pipeline_suffix.as_deref(),
276+
);
277+
add_warning(deprecation.warning(&invocation.command_path, &replacement))?;
278+
}
150279
set_render_format(render_format(&tokens)?)?;
151280
set_table_headers(table_headers(&tokens)?)?;
152281

@@ -163,3 +292,57 @@ where
163292
.map_err(|err| AppError::CommandExecutionError(err.to_string()))?
164293
}
165294
}
295+
296+
#[cfg(test)]
297+
mod tests {
298+
use super::CommandDeprecation;
299+
use crate::tokenizer::CommandTokenizer;
300+
301+
const RENAMED_FIELDS: CommandDeprecation = CommandDeprecation::renamed(
302+
&["class", "fields"],
303+
&[("--class", "--name"), ("-c", "--name")],
304+
);
305+
306+
#[test]
307+
fn command_deprecation_builds_an_exact_replacement() {
308+
let tokens = CommandTokenizer::new(
309+
"object fields --class 'Host Group' --limit 10 --containers",
310+
"fields",
311+
&[],
312+
)
313+
.expect("deprecated command should tokenize");
314+
315+
let replacement = RENAMED_FIELDS.replacement_command(&tokens, 1, None);
316+
317+
assert_eq!(
318+
replacement,
319+
"class fields --name 'Host Group' --limit 10 --containers"
320+
);
321+
assert_eq!(
322+
RENAMED_FIELDS.warning(&["object".to_string(), "fields".to_string()], &replacement),
323+
"Command 'object fields' is deprecated; use `class fields --name 'Host Group' --limit 10 --containers` instead."
324+
);
325+
}
326+
327+
#[test]
328+
fn command_deprecation_replaces_scoped_invocations_and_short_options() {
329+
let tokens = CommandTokenizer::new("fields -c Hosts --depth 4", "fields", &[])
330+
.expect("scoped deprecated command should tokenize");
331+
332+
assert_eq!(
333+
RENAMED_FIELDS.replacement_command(&tokens, 0, Some("| P Field Source")),
334+
"class fields --name Hosts --depth 4 | P Field Source"
335+
);
336+
}
337+
338+
#[test]
339+
fn command_deprecation_does_not_rewrite_positionals_after_double_dash() {
340+
let tokens = CommandTokenizer::new("fields -c Hosts -- --class", "fields", &[])
341+
.expect("double-dash command should tokenize");
342+
343+
assert_eq!(
344+
RENAMED_FIELDS.replacement_command(&tokens, 0, None),
345+
"class fields --name Hosts -- --class"
346+
);
347+
}
348+
}

0 commit comments

Comments
 (0)