merge(cycle-2): land Stream C — Plan B Phases 7+8 (core/wasm seam)

3 commits from feature/cli-tail-stream-c-core-wasm-seam:
- e5d63ab refactor(core): extract base32 module, dedupe two RFC 4648 impls
- 03f2a1b refactor(core,cli): migrate CLI parsers to relicario-core, parse.rs becomes shim
- fc9264e feat(wasm): add parse_month_year, base32_decode_lenient, guess_mime exports

Phase 7 complete: parser bodies (MonthYear::parse, mime::guess_for_extension,
TotpConfig::parse_secret) lifted into relicario-core; CLI parse.rs reduced to
19-line thin shims; callsites unchanged. base32 codec deduplicated from two
inline implementations (item.rs encoder + import_lastpass.rs decoder) into
crate::base32::{encode_rfc4648, decode_rfc4648_lenient}. Steam Guards
non-RFC-4648 alphabet stays at item_types/totp.rs:13 with a neighbour
comment cross-referencing the standard module.

Phase 8 complete: 3 #[wasm_bindgen] exports (parse_month_year,
base32_decode_lenient, guess_mime) with snake_case JS names per existing
convention. extension/src/wasm.d.ts mirror landed in the same commit
(fc9264e) per kickoff hard-rule.

Spec deviation (PM ack 02:55Z + 15:13Z): pub(crate) mod base32 promoted to
pub mod base32 because the CLI shim AND the Phase 8 WASM exports both
require external reach. Justification documented in lib.rs module-list
comment + module-level docstring on base32.rs explicitly carving Steam
Guard out as a non-user.

Two new RelicarioError variants added (additive, non-breaking):
- InvalidBase32(String)
- InvalidMonthYear(String)

3-way merge with stream-a (3dd1e1b) clean: stream-c didn't actually modify
add.rs or prompt.rs, so the diff stat showing those files was just stream-c
being behind on stream-a's changes. ort strategy auto-took mains versions.

Pre-merge checklist on tip fc9264e + post-merge verification:
- cargo test --workspace standalone: 272 tests, 0 failures
- cargo test --workspace post-merge: 277 tests, 0 failures (5 added from stream-a)
- cargo clippy --workspace --all-targets: silent (both standalone + post-merge)
- cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean
- Extension vitest: 17 failed / 335 passed -- matches cycle-1 baseline cluster, no new regressions
- Independent fresh-subagent code review: APPROVE-WITH-NITS
  - nit 1: stale doc-comment in extension/src/shared/base32.ts:3 (Plan C concern, deferred)
  - nit 2: TotpConfig::parse_secret unused on this branch (spec-driven forward-compat for Plan C SW handlers)

Plan B Phases 7+8 complete. With Phase 3 (Stream A) already merged at 3dd1e1b,
only Phases 4+5+6 (Stream B in flight) remain to close out Plan B.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-09 11:44:08 -04:00
11 changed files with 324 additions and 86 deletions

View File

@@ -1,47 +1,19 @@
//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess).
//!
//! Phase 7 of the CLI restructure migrates these to `relicario-core` and
//! turns this file into a thin re-export shim. They live here for now so
//! the Phase 1 relocation stays mechanical.
//! Thin shims over `relicario-core`'s migrated parsers, kept here so existing
//! CLI callsites need no import churn. Plan B Phase 7 moved the bodies into
//! `relicario_core::{time::MonthYear::parse, base32::decode_rfc4648_lenient,
//! mime::guess_for_extension}`.
use anyhow::{Context, Result};
use anyhow::Result;
use relicario_core::MonthYear;
pub(crate) fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
// Accepts MM/YYYY or MM-YYYY or MM/YY.
let (m_str, y_str) = s.split_once(['/', '-'])
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
let month: u8 = m_str.parse().context("invalid month")?;
let year: u16 = if y_str.len() == 2 {
2000 + y_str.parse::<u16>().context("invalid 2-digit year")?
} else {
y_str.parse().context("invalid year")?
};
Ok(relicario_core::MonthYear { month, year })
pub(crate) fn parse_month_year(s: &str) -> Result<MonthYear> {
Ok(MonthYear::parse(s)?)
}
pub(crate) fn guess_mime(filename: &str) -> String {
let lower = filename.to_ascii_lowercase();
match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
"pdf" => "application/pdf",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"txt" => "text/plain",
"json" => "application/json",
_ => "application/octet-stream",
}.to_string()
relicario_core::mime::guess_for_extension(filename).to_string()
}
pub(crate) fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
let cleaned: String = s.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_ascii_uppercase()
.trim_end_matches('=')
.to_string();
let padded = {
let rem = cleaned.len() % 8;
if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) }
};
data_encoding::BASE32.decode(padded.as_bytes())
.map_err(|e| anyhow::anyhow!("invalid base32: {e}"))
Ok(relicario_core::base32::decode_rfc4648_lenient(s)?)
}