Migrated from specs/389-list-command-migration/README.md. Originally created 2026-05-14.
Depends on: #258, #259, #263 (rewritten to issue numbers on migration)
Overview
Migrate leanspec list from SpecLoader/SpecInfo to AdapterRegistry.
This is the reference implementation: the pattern established here — semantic
hint translation, ListFilter::fields map, schema-driven output — is the
template for all subsequent command migrations (390–392).
Done when: leanspec list works correctly against both a markdown project and
a GitHub-backed project, with no SpecLoader/SpecInfo imports remaining.
Design
Entry point pattern
Every adapter-aware command starts with:
pub fn run(params: ListParams) -> Result<(), Box<dyn Error>> {
let adapter = AdapterRegistry::from_project()?;
let schema = adapter.schema();
let caps = adapter.capabilities();
// … build filter, call adapter, render output
}
The --specs-dir flag is markdown-only. If provided with a non-markdown adapter:
if params.specs_dir.is_some() && caps.name != "markdown" {
return Err(CliError::NotApplicable {
flag: "--specs-dir",
adapter: caps.name.clone(),
}.into());
}
For markdown with --specs-dir, the adapter is constructed directly:
MarkdownAdapter::new(specs_dir) instead of AdapterRegistry::from_project().
Semantic hint translation
Filter flags (--status, --priority, --assignee, --tag) are translated to
ListFilter::fields using the schema:
fn build_list_filter(params: &ListParams, schema: &SpecSchema) -> ListFilter {
let mut fields: HashMap<String, Vec<String>> = HashMap::new();
if let Some(status) = ¶ms.status {
if let Some(key) = schema.key_for_semantic(semantic::STATUS) {
fields.insert(key.to_string(), vec![status.clone()]);
}
}
if let Some(priority) = ¶ms.priority {
if let Some(key) = schema.key_for_semantic(semantic::PRIORITY) {
fields.insert(key.to_string(), vec![priority.clone()]);
}
}
if let Some(tags) = ¶ms.tags {
if let Some(key) = schema.key_for_semantic(semantic::TAGS) {
fields.insert(key.to_string(), tags.clone());
}
}
if let Some(assignee) = ¶ms.assignee {
if let Some(key) = schema.key_for_semantic(semantic::ASSIGNEE) {
fields.insert(key.to_string(), vec![assignee.clone()]);
}
}
ListFilter {
fields,
text: params.search.clone(),
include_archived: params.include_archived.unwrap_or(false),
raw: None,
}
}
If a flag is provided but the adapter's schema has no matching field, the flag
is silently ignored (the adapter simply has no such concept). This is correct:
--priority high on a GitHub adapter with no priority field is a no-op, not an
error, because the user may have a generic script that passes the flag regardless.
Schema-driven output rendering
Replace hard-coded field accesses (spec.frontmatter.status.to_string(), etc.)
with schema-aware rendering:
fn render_row(doc: &SpecDoc, schema: &SpecSchema, compact: bool) -> String {
let id_col = &doc.id;
let title_col = &doc.title;
let status_col = schema
.key_for_semantic(semantic::STATUS)
.and_then(|k| doc.fields.get(k))
.and_then(|v| v.as_string())
.map(|s| colorize_status(s))
.unwrap_or_default();
let priority_col = schema
.key_for_semantic(semantic::PRIORITY)
.and_then(|k| doc.fields.get(k))
.and_then(|v| v.as_string())
.unwrap_or_default();
// … format row
}
Status colorization (colorize_status) maps enum values to colors using the
color field declared on the enum option in the schema, rather than hard-coding
SpecStatus variant → color mappings.
--hierarchy flag
Hierarchy display (parent/child tree) uses doc.links filtered by link type
"parent" / "child". Works for any adapter that populates links. If the
adapter returns no links, hierarchy display falls back to flat list.
Output formats
--output json serializes Vec<SpecDoc> as JSON. --output table (default)
uses the schema-driven row renderer. --output compact is the existing one-line
format.
ListParams changes
Remove specs_dir: String (was the only required parameter) — it's now optional
and only applies to markdown. Add adapter_config: Option<PathBuf> for
overriding the config file location in tests.
Plan
Test
Notes
Template for all subsequent command migrations
The pattern in this spec — AdapterRegistry::from_project(), build_*_filter()
with semantic hints, schema-driven rendering, --specs-dir hard error — is
copy-pasted as the starting point for specs 390, 391, 392. Do not deviate from
this pattern without updating this spec as the canonical reference.
FieldValue::as_string() helper
The FieldValue enum needs a convenience method:
impl FieldValue {
pub fn as_string(&self) -> Option<&str> {
match self {
FieldValue::String(s) => Some(s),
_ => None,
}
}
pub fn as_strings(&self) -> Option<&[String]> {
match self {
FieldValue::Strings(v) => Some(v),
_ => None,
}
}
}
Add these in #258 or here if they're missing from model.rs.
Overview
Migrate
leanspec listfromSpecLoader/SpecInfotoAdapterRegistry.This is the reference implementation: the pattern established here — semantic
hint translation,
ListFilter::fieldsmap, schema-driven output — is thetemplate for all subsequent command migrations (390–392).
Done when:
leanspec listworks correctly against both a markdown project anda GitHub-backed project, with no
SpecLoader/SpecInfoimports remaining.Design
Entry point pattern
Every adapter-aware command starts with:
The
--specs-dirflag is markdown-only. If provided with a non-markdown adapter:For markdown with
--specs-dir, the adapter is constructed directly:MarkdownAdapter::new(specs_dir)instead ofAdapterRegistry::from_project().Semantic hint translation
Filter flags (
--status,--priority,--assignee,--tag) are translated toListFilter::fieldsusing the schema:If a flag is provided but the adapter's schema has no matching field, the flag
is silently ignored (the adapter simply has no such concept). This is correct:
--priority highon a GitHub adapter with no priority field is a no-op, not anerror, because the user may have a generic script that passes the flag regardless.
Schema-driven output rendering
Replace hard-coded field accesses (
spec.frontmatter.status.to_string(), etc.)with schema-aware rendering:
Status colorization (
colorize_status) maps enum values to colors using thecolorfield declared on the enum option in the schema, rather than hard-codingSpecStatusvariant → color mappings.--hierarchyflagHierarchy display (parent/child tree) uses
doc.linksfiltered by link type"parent"/"child". Works for any adapter that populates links. If theadapter returns no links, hierarchy display falls back to flat list.
Output formats
--output jsonserializesVec<SpecDoc>as JSON.--output table(default)uses the schema-driven row renderer.
--output compactis the existing one-lineformat.
ListParamschangesRemove
specs_dir: String(was the only required parameter) — it's now optionaland only applies to markdown. Add
adapter_config: Option<PathBuf>foroverriding the config file location in tests.
Plan
ListParamsstruct — makespecs_diroptionalcommands/list.rs::run():AdapterRegistry::from_project()at entry--specs-dirhard error for non-markdownbuild_list_filter()with semantic hint translationrender_row()with schema-driven field lookup and colorize-from-schemacolorize_status(value: &str, schema: &SpecSchema) -> StringSpecLoader,SpecInfo,SpecStatus,SpecPriority,SpecFilterOptionsimportsmain.rs/cli_args.rsfor changedListParamsrust/leanspec-cli/tests/list.rs:TestContextwith mock GitHub).agents/skills/leanspec-development/references/CI-COMMANDS.mdas the canonical pattern for command migrations
Test
tests/list.rsE2E tests passleanspec liston a markdown project: identical output to pre-specleanspec list --status plannedon markdown: filters correctlyleanspec list --status openon GitHub project: filters correctlyleanspec list --specs-dir ./otheron GitHub project: exits with clear errorleanspec list --output jsonon GitHub project: valid JSON with correct fieldsleanspec list --priority highwhen adapter has no priority field: runs without errorSpecLoader,SpecInfo,SpecStatus,SpecPriorityimports inlist.rsNotes
Template for all subsequent command migrations
The pattern in this spec —
AdapterRegistry::from_project(),build_*_filter()with semantic hints, schema-driven rendering,
--specs-dirhard error — iscopy-pasted as the starting point for specs 390, 391, 392. Do not deviate from
this pattern without updating this spec as the canonical reference.
FieldValue::as_string()helperThe
FieldValueenum needs a convenience method:Add these in #258 or here if they're missing from
model.rs.