#![allow(clippy::too_many_lines)]
#![allow(clippy::doc_markdown)]
use anyhow::{Context, Result, bail};
use astrogram::astrocom::{AstrocomCredential, AstrocomSession};
use astrogram::astrotheoros::{AstrotheorosCredential, AstrotheorosSession};
use astrogram::consolidate;
use astrogram::cookie_import::Browser;
use astrogram::format::{Format, Kind};
use astrogram::luna::LunaSession;
use astrogram::normalize::normalize_chart;
use astrogram::util::{expand_now, utc_timestamp};
use clap::Parser;
use std::collections::HashMap;
use std::io::Write as _;
use std::path::{Path, PathBuf};
pub use astrogram::format::Format as Target;
mod consolidate_ui;
mod providers;
use providers::WebProvider;
fn parse_format(s: &str) -> Result<Target, String> {
Format::from_slug(s).ok_or_else(|| {
let slugs: Vec<&str> = Format::all().iter().map(|spec| spec.slug).collect();
format!(
"unknown format '{s}'; expected one of: {}",
slugs.join(", ")
)
})
}
fn parse_browser(s: &str) -> Result<GrantArg, String> {
match s {
"all" => Ok(GrantArg::All),
"chrome" => Ok(GrantArg::One(Browser::Chrome)),
"chromium" => Ok(GrantArg::One(Browser::Chromium)),
"brave" => Ok(GrantArg::One(Browser::Brave)),
"edge" => Ok(GrantArg::One(Browser::Edge)),
"opera" => Ok(GrantArg::One(Browser::Opera)),
"vivaldi" => Ok(GrantArg::One(Browser::Vivaldi)),
"whale" => Ok(GrantArg::One(Browser::Whale)),
"firefox" => Ok(GrantArg::One(Browser::Firefox)),
"safari" => Ok(GrantArg::One(Browser::Safari)),
other => Err(format!(
"unknown browser '{other}'; valid values: \
all, chrome, chromium, brave, edge, opera, vivaldi, whale, firefox, safari"
)),
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum GrantArg {
All,
One(Browser),
}
#[derive(Debug, PartialEq)]
enum GrantChoice {
NoGrant,
AllStores,
One(Browser),
}
fn grant_choice(flag: &Option<GrantArg>) -> GrantChoice {
match flag {
None => GrantChoice::NoGrant,
Some(GrantArg::All) => GrantChoice::AllStores,
Some(GrantArg::One(b)) => GrantChoice::One(*b),
}
}
#[derive(Parser)]
#[command(
name = "blackmoon",
about = "Astrology data converter — reads any target, writes any target.",
long_about = "\
Reads one or more source targets (files or web endpoints), merges and deduplicates,
then writes to an output target. Target type is detected from the file
extension (.SFcht, .zdb, .xml) or specified with --from / --to.
Each write is preceded by a read of the output target (if it already exists)
so no duplicate records are ever added.
Examples:
blackmoon input.zdb --output out.SFcht
blackmoon a.SFcht b.zdb export.xml --output merged.SFcht
blackmoon --from luna --luna-token $LUNA_TOKEN --output charts.SFcht
blackmoon --from astrotheoros --astrotheoros-user $USER --astrotheoros-pass $PASS --output charts.SFcht
blackmoon charts.SFcht --normalize
blackmoon *.SFcht --normalize"
)]
struct Cli {
inputs: Vec<PathBuf>,
#[arg(short, long, alias = "out")]
output: Option<PathBuf>,
#[arg(long, value_parser = parse_format)]
from: Option<Target>,
#[arg(long, value_parser = parse_format)]
to: Option<Target>,
#[arg(long, value_parser = parse_format)]
target: Option<Target>,
#[arg(long)]
normalize: bool,
#[arg(long, env = "LUNA_TOKEN", hide_env_values = true)]
luna_token: Option<String>,
#[arg(long, default_value = "500")]
delay: u64,
#[arg(long)]
luna_resume_from: Option<String>,
#[arg(long, env = "ASTROCOM_TOKEN", hide_env_values = true)]
astrocom_token: Option<String>,
#[arg(long, env = "ASTROCOM_USER", hide_env_values = true)]
astrocom_user: Option<String>,
#[arg(long, env = "ASTROCOM_PASS", hide_env_values = true)]
astrocom_pass: Option<String>,
#[arg(long, env = "ASTROTHEOROS_USER", hide_env_values = true)]
astrotheoros_user: Option<String>,
#[arg(long, env = "ASTROTHEOROS_PASS", hide_env_values = true)]
astrotheoros_pass: Option<String>,
#[arg(long, env = "ASTROTHEOROS_TOKEN", hide_env_values = true)]
astrotheoros_token: Option<String>,
#[arg(long)]
clear: bool,
#[arg(long)]
consolidate: bool,
#[arg(long)]
decision_log: Option<PathBuf>,
#[arg(long)]
no_verify: bool,
#[arg(long)]
strict: bool,
#[arg(long)]
fill_house: Option<String>,
#[arg(long)]
fill_zodiac: Option<String>,
#[arg(long)]
fill_locus: Option<String>,
#[arg(long, short)]
verbose: bool,
#[arg(long, value_name = "BROWSER", num_args = 0..=1, default_missing_value = "all",
value_parser = parse_browser)]
grant_cookie_access: Option<GrantArg>,
#[arg(long, value_name = "NAME", requires = "grant_cookie_access")]
cookies_profile: Option<String>,
#[arg(long = "generate-completion", value_name = "SHELL", num_args = 0..=1, default_missing_value = "auto", hide = true)]
generate_completion: Option<String>,
}
fn main() -> Result<()> {
let cli = Cli::parse();
if let Some(shell_str) = &cli.generate_completion {
use clap::CommandFactory;
let shell = if shell_str == "auto" {
detect_shell()
.context("could not detect shell from $SHELL; pass it explicitly (e.g. --generate-completion zsh)")?
} else {
shell_str.parse::<clap_complete::Shell>().map_err(|_| {
anyhow::anyhow!(
"unknown shell '{shell_str}'; valid values: bash, elvish, fish, powershell, zsh"
)
})?
};
clap_complete::generate(
shell,
&mut Cli::command(),
"blackmoon",
&mut std::io::stdout(),
);
return Ok(());
}
let nothing = cli.inputs.is_empty()
&& cli.output.is_none()
&& cli.from.is_none()
&& cli.to.is_none()
&& cli.target.is_none()
&& !cli.normalize
&& !cli.consolidate
&& !cli.clear;
if nothing {
use clap::CommandFactory;
Cli::command().print_long_help()?;
return Ok(());
}
if cli.normalize
&& cli.output.is_none()
&& cli.to.is_none()
&& cli.from.is_none()
&& cli.target.is_none()
{
return cmd_normalize_inplace(&cli.inputs);
}
cmd_convert(&cli)
}
fn resolve_fill<T>(
label: &str,
flag_suffix: &str,
flag: Option<&str>,
suggested: &str,
parse: impl Fn(&str) -> Result<T>,
sink: Format,
) -> Result<T> {
use std::io::IsTerminal;
if let Some(s) = flag {
return parse(s);
}
if std::io::stdin().is_terminal() {
eprintln!(
"{} stores {label} per chart; your source did not provide one.",
sink.spec().slug
);
eprint!("Value for {label} [{suggested}]: ");
std::io::stderr().flush().ok();
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let chosen = line.trim();
let chosen = if chosen.is_empty() { suggested } else { chosen };
return parse(chosen);
}
bail!(
"{} requires a {label} but the source provided none; pass --fill-{flag_suffix} (non-interactive)",
sink.spec().slug,
)
}
fn apply_fills(
merged: &mut [astrogram::chart::Chart],
fills: &[astrogram::capability::ChartField],
source_of: &std::collections::HashMap<providers::DatetimeKey, Format>,
cli: &Cli,
sink: Format,
) -> Result<()> {
use astrogram::capability::ChartField;
enum Fill {
House(astrogram::chart::HouseSystem),
Zodiac(astrogram::chart::Zodiac),
Locus(astrogram::chart::CoordinateSystem),
}
for &field in fills {
let resolved = match field {
ChartField::HouseSystem => Fill::House(resolve_fill(
"house system",
"house",
cli.fill_house.as_deref(),
"placidus",
|s| {
astrogram::chart::HouseSystem::from_str_slug(s)
.ok_or_else(|| anyhow::anyhow!("unknown house system '{s}'"))
},
sink,
)?),
ChartField::Zodiac => Fill::Zodiac(resolve_fill(
"zodiac",
"zodiac",
cli.fill_zodiac.as_deref(),
"tropical",
|s| {
astrogram::chart::Zodiac::from_str_slug(s)
.ok_or_else(|| anyhow::anyhow!("unknown zodiac '{s}'"))
},
sink,
)?),
ChartField::CoordinateSystem => Fill::Locus(resolve_fill(
"locus",
"locus",
cli.fill_locus.as_deref(),
"geocentric",
|s| {
astrogram::chart::CoordinateSystem::from_str_slug(s).ok_or_else(|| {
anyhow::anyhow!("unknown locus '{s}' (expected geocentric|heliocentric)")
})
},
sink,
)?),
_ => continue, };
for c in merged.iter_mut() {
let src = source_of.get(&providers::key(c)).copied().unwrap_or(sink);
if src.read_caps().preserves(field) {
continue; }
match resolved {
Fill::House(v) => c.house_system = v,
Fill::Zodiac(v) => c.zodiac = v,
Fill::Locus(v) => c.coordinate_system = v,
}
}
}
Ok(())
}
fn is_web_target(t: Target) -> bool {
matches!(t.spec().kind, Kind::Web)
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum SourceKind {
Cookie,
Token,
Login,
}
fn source_label(kinds: &[SourceKind], idx: usize) -> &'static str {
match kinds.get(idx) {
Some(SourceKind::Cookie) => "browser cookie",
Some(SourceKind::Token) => "token",
Some(SourceKind::Login) => "login",
None => "unknown source",
}
}
struct CookieDisclosure {
domain: String,
found_in: Vec<(Browser, String, i64)>,
winner: String,
}
impl CookieDisclosure {
fn print(&self, verbose: bool) {
let labels: Vec<String> = self
.found_in
.iter()
.map(|(b, p, _)| store_label(*b, p))
.collect();
let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect();
if self.found_in.len() > 1 {
eprintln!(
"found {} logged in on {}. Using {} as it is the most recent.",
self.domain,
oxford_join(&label_refs),
self.winner,
);
} else {
eprintln!(
"found {} logged in on {}.",
self.domain,
oxford_join(&label_refs)
);
}
if verbose {
let now = now_secs() as i64;
for (b, p, freshness) in &self.found_in {
let label = store_label(*b, p);
if *freshness > 1_000_000_000 {
let delta = freshness - now;
let when = if delta >= 0 {
format!("session expires in {delta}s")
} else {
format!("session expired {}s ago (stale on-disk snapshot)", -delta)
};
eprintln!(" {label}: {when}");
} else {
eprintln!(" {label}: session present (no expiry signal)");
}
}
}
}
}
fn announce_source(kinds: &[SourceKind], used: usize) {
let label = source_label(kinds, used);
if used > 0 {
eprintln!("authenticated via {label} (earlier source(s) were stale).");
} else {
eprintln!("authenticated via {label}.");
}
}
fn resolve_provider(target: Target, cli: &Cli) -> Result<WebProvider> {
use astrogram::cookie_import;
let choice = grant_choice(&cli.grant_cookie_access);
let want_cookie = choice != GrantChoice::NoGrant;
let browser: Option<Browser> = match &choice {
GrantChoice::AllStores | GrantChoice::NoGrant => None,
GrantChoice::One(b) => Some(*b),
};
match target {
Target::Astrotheoros => {
let mut kinds: Vec<SourceKind> = Vec::new();
let mut chain: Vec<AstrotheorosCredential> = Vec::new();
let mut disclosure: Option<CookieDisclosure> = None;
if want_cookie {
match cookie_import::import_credential(
Format::Astrotheoros,
browser,
cli.cookies_profile.as_deref(),
) {
Ok(out) => {
if let cookie_import::ProviderCredential::Astrotheoros(c) = out.credential {
disclosure = Some(CookieDisclosure {
domain: out.domain.clone(),
found_in: out.found_in.clone(),
winner: store_label(out.browser, &out.profile),
});
kinds.push(SourceKind::Cookie);
chain.push(c);
}
}
Err(e) => eprintln!(
"note: no usable astrotheoros.com cookie ({e}); trying other sources"
),
}
}
if let Some(token) = cli.astrotheoros_token.as_deref() {
let parts: Vec<&str> = token.splitn(3, ':').collect();
if parts.len() != 3 {
bail!("--astrotheoros-token must be 'jwt:session_id:client_uat'");
}
kinds.push(SourceKind::Token);
chain.push(AstrotheorosCredential::Token {
jwt: parts[0].to_string(),
session_id: parts[1].to_string(),
client_uat: parts[2].to_string(),
});
}
match (&cli.astrotheoros_user, &cli.astrotheoros_pass) {
(Some(u), Some(p)) => {
kinds.push(SourceKind::Login);
chain.push(AstrotheorosCredential::Login {
email: u.clone(),
password: p.clone(),
});
}
(Some(_), None) => bail!(
"--astrotheoros-pass (or ASTROTHEOROS_PASS) required with --astrotheoros-user"
),
(None, Some(_)) => bail!(
"--astrotheoros-user (or ASTROTHEOROS_USER) required with --astrotheoros-pass"
),
(None, None) => {}
}
if chain.is_empty() {
bail!(
"no astrotheoros.com credentials: pass --grant-cookie-access, \
--astrotheoros-token, or --astrotheoros-user/--pass"
);
}
if let Some(d) = &disclosure {
d.print(cli.verbose);
}
let (session, used) = AstrotheorosSession::authenticate(&chain, cli.delay)
.context("astrotheoros.com authentication failed for every source")?;
announce_source(&kinds, used);
Ok(WebProvider::Astrotheoros {
session,
uuid_map: std::collections::HashMap::new(),
})
}
Target::Astrocom => {
let mut kinds: Vec<SourceKind> = Vec::new();
let mut chain: Vec<AstrocomCredential> = Vec::new();
let mut disclosure: Option<CookieDisclosure> = None;
if want_cookie {
match cookie_import::import_credential(
Format::Astrocom,
browser,
cli.cookies_profile.as_deref(),
) {
Ok(out) => {
if let cookie_import::ProviderCredential::Astrocom(c) = out.credential {
disclosure = Some(CookieDisclosure {
domain: out.domain.clone(),
found_in: out.found_in.clone(),
winner: store_label(out.browser, &out.profile),
});
kinds.push(SourceKind::Cookie);
chain.push(c);
}
}
Err(e) => {
eprintln!("note: no usable astro.com cookie ({e}); trying other sources")
}
}
}
if let Some(cid) = cli.astrocom_token.as_deref() {
kinds.push(SourceKind::Token);
chain.push(AstrocomCredential::Cookie(cid.to_string()));
}
match (&cli.astrocom_user, &cli.astrocom_pass) {
(Some(u), Some(p)) => {
kinds.push(SourceKind::Login);
chain.push(AstrocomCredential::Login {
email: u.clone(),
password: p.clone(),
});
}
(Some(_), None) => {
bail!("--astrocom-pass (or ASTROCOM_PASS) required with --astrocom-user")
}
(None, Some(_)) => {
bail!("--astrocom-user (or ASTROCOM_USER) required with --astrocom-pass")
}
(None, None) => {}
}
if chain.is_empty() {
bail!(
"no astro.com credentials: pass --grant-cookie-access, \
--astrocom-token, or --astrocom-user/--pass"
);
}
if let Some(d) = &disclosure {
d.print(cli.verbose);
}
let auth = AstrocomSession::authenticate(&chain, cli.delay)
.context("astro.com authentication failed for every source")?;
announce_source(&kinds, auth.source);
Ok(WebProvider::Astrocom {
session: auth.session,
creds: auth.login,
nhor_id_map: std::collections::HashMap::new(),
})
}
Target::Luna => {
let mut kinds: Vec<SourceKind> = Vec::new();
let mut cookies: Vec<String> = Vec::new();
let mut disclosure: Option<CookieDisclosure> = None;
if want_cookie {
match cookie_import::import_credential(
Format::Luna,
browser,
cli.cookies_profile.as_deref(),
) {
Ok(out) => {
if let cookie_import::ProviderCredential::Luna(tok) = out.credential {
disclosure = Some(CookieDisclosure {
domain: out.domain.clone(),
found_in: out.found_in.clone(),
winner: store_label(out.browser, &out.profile),
});
kinds.push(SourceKind::Cookie);
cookies.push(tok);
}
}
Err(e) => eprintln!("note: no usable LUNA cookie ({e}); trying other sources"),
}
}
if let Some(token) = cli.luna_token.as_deref() {
kinds.push(SourceKind::Token);
cookies.push(token.to_string());
}
if cookies.is_empty() {
bail!("no LUNA credentials: pass --grant-cookie-access or --luna-token");
}
if let Some(d) = &disclosure {
d.print(cli.verbose);
}
let refs: Vec<&str> = cookies.iter().map(String::as_str).collect();
let (session, used) = LunaSession::authenticate(&refs, cli.delay)
.context("LUNA authentication failed for every source")?;
announce_source(&kinds, used);
Ok(WebProvider::Luna {
session,
resume_from: cli.luna_resume_from.clone(),
normalize: cli.normalize,
listing_keys: std::collections::HashSet::new(),
phenom_ids: Vec::new(),
})
}
other => unreachable!("resolve_provider called for non-web target {other:?}"),
}
}
fn store_label(browser: Browser, profile: &str) -> String {
if profile.is_empty() || profile.eq_ignore_ascii_case("default") {
browser.display_name().to_string()
} else {
format!("{} ({profile})", browser.display_name())
}
}
fn oxford_join(items: &[&str]) -> String {
match items {
[] => String::new(),
[a] => (*a).to_string(),
[a, b] => format!("{a} and {b}"),
[rest @ .., last] => format!("{}, and {last}", rest.join(", ")),
}
}
fn cmd_convert(cli: &Cli) -> Result<()> {
let from = cli.from.or(cli.target);
let to = cli.to.or(cli.target);
if cli.clear {
let target = cli
.target
.or(cli.from)
.or(cli.to)
.filter(|t| is_web_target(*t))
.context("--clear requires a web target (--target luna / astrocom / astrotheoros)")?;
let provider = resolve_provider(target, cli)?;
return cmd_clear(provider);
}
if cli.consolidate {
let target = cli
.target
.or(cli.from)
.or(cli.to)
.filter(|t| is_web_target(*t))
.context(
"--consolidate requires a web target (--target luna / astrocom / astrotheoros)",
)?;
let provider = resolve_provider(target, cli)?;
return cmd_consolidate(provider, cli);
}
let resolved_output: Option<PathBuf> = match &cli.output {
Some(p) => Some(expand_now(p, now_secs())),
None if from.map(is_web_target).unwrap_or(false) && !cli.normalize => {
Some(PathBuf::from(format!("{}.SFcht", utc_timestamp())))
}
None => None,
};
let effective_to = if from.map(is_web_target).unwrap_or(false)
&& cli.normalize
&& cli.output.is_none()
&& to.is_none()
{
from
} else {
to
};
let out_target = match (effective_to, resolved_output.as_deref()) {
(Some(t), _) => t,
(None, Some(p)) => Format::from_path(p).with_context(|| {
format!(
"cannot detect target from '{}'; use --to to specify",
p.display()
)
})?,
(None, None) => bail!("--output (or --to luna / --to astrocom) is required"),
};
let out_path = if is_web_target(out_target) {
None
} else {
Some(
resolved_output
.as_ref()
.context("--output is required when writing to a file target")?,
)
};
let to_stdout = out_path.is_some_and(|p| p == Path::new("-"));
let mut out_provider: Option<WebProvider> = if is_web_target(out_target) {
Some(resolve_provider(out_target, cli)?)
} else {
None
};
let mut in_provider: Option<WebProvider> =
if from.map(is_web_target).unwrap_or(false) && from != Some(out_target) {
Some(resolve_provider(from.unwrap(), cli)?)
} else {
None
};
let mut existing: Vec<astrogram::chart::Chart> = Vec::new();
if let Some(p) = &mut out_provider {
if from != Some(out_target) {
existing = p.read_existing()?;
}
} else if let Some(p) = out_path {
if p.exists() {
existing = read_file_target(p, out_target)
.with_context(|| format!("reading existing output {}", p.display()))?;
if !to_stdout {
println!("{}: {} charts (existing)", p.display(), existing.len());
}
}
}
let mut source_of: HashMap<providers::DatetimeKey, Format> = HashMap::new();
for chart in &existing {
source_of.entry(providers::key(chart)).or_insert(out_target);
}
let mut batches: Vec<Vec<astrogram::chart::Chart>> = vec![existing];
if let Some(p) = &mut in_provider {
let charts = p.read_input()?;
for chart in &charts {
source_of
.entry(providers::key(chart))
.or_insert(from.expect("in_provider is Some only when --from/--target is set"));
}
batches.push(charts);
} else if from.map(is_web_target).unwrap_or(false) && from == Some(out_target) {
let charts = out_provider.as_mut().unwrap().read_input()?;
for chart in &charts {
source_of.entry(providers::key(chart)).or_insert(out_target);
}
batches.push(charts);
} else {
if cli.inputs.is_empty() {
bail!(
"at least one input file is required (or use --from / --target luna / --target astro)"
);
}
for path in &cli.inputs {
let target = Format::from_path(path).with_context(|| {
format!(
"cannot detect target from '{}'; rename the file or use --from to specify",
path.display()
)
})?;
let charts = read_file_target(path, target)
.with_context(|| format!("reading {}", path.display()))?;
if !to_stdout {
println!("{}: {} charts", path.display(), charts.len());
}
for chart in &charts {
source_of.entry(providers::key(chart)).or_insert(target);
}
batches.push(charts);
}
}
let existing_count: usize = batches[0].len();
let new_input_count: usize = batches[1..].iter().map(Vec::len).sum();
let (mut merged, skipped) = consolidate::merge_reporting(&batches);
let dupes = skipped.len();
if cli.normalize {
for chart in &mut merged {
normalize_chart(chart);
}
}
let dropped = report_drops(&merged, &source_of, out_target, to_stdout);
if dropped > 0 && cli.strict {
bail!(
"--strict: {dropped} chart(s) would lose data writing to {}; aborting",
out_target.spec().slug
);
}
{
let mut needed: Vec<astrogram::capability::ChartField> = Vec::new();
let sources: std::collections::HashSet<Format> = source_of.values().copied().collect();
for src in &sources {
for f in astrogram::capability::fill_fields(*src, out_target) {
if !needed.contains(&f) {
needed.push(f);
}
}
}
if !needed.is_empty() {
apply_fills(&mut merged, &needed, &source_of, cli, out_target)?;
}
}
if let Some(p) = &mut out_provider {
if cli.normalize {
println!("Charts to write ({}):", merged.len());
for chart in &merged {
println!(" {}", chart.name);
}
eprint!(
"About to write {} chart{} to your {} account. Proceed? [y/N] ",
merged.len(),
if merged.len() == 1 { "" } else { "s" },
p.site_display(),
);
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("reading confirmation")?;
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
eprintln!("Aborted.");
return Ok(());
}
}
let inline = p.verifies_inline();
let verify = !cli.no_verify;
let global = if inline {
p.fetch_global_settings()?
} else {
None
};
let mut verified = 0usize;
let write_results = {
let mut on_landed = |n: usize,
total: usize,
source: &astrogram::chart::Chart,
landed: Option<&astrogram::chart::Chart>,
status: &str| {
let w = total.to_string().len();
println!("[{n:0>w$}/{total}] {} {status}", source.name);
if let Some(landed) = landed {
if verify {
let mut folded = landed.clone();
let notes: &[(astrogram::capability::ChartField, &'static str)] =
if let Some(g) = &global {
folded.house_system = g.house_system;
folded.zodiac = g.zodiac;
folded.coordinate_system = g.coordinate_system;
&g.field_notes
} else {
&[]
};
let mappings = astrogram::transcript::diff(source, &folded, notes);
print_transcript(&mappings);
}
verified += 1;
}
};
p.write_charts(&merged, &mut on_landed)?
};
if inline {
let created = write_results.iter().filter(|r| r.is_some()).count();
println!(
"verified {verified}/{created} charts (create response — no readback) from {}",
p.site_display()
);
} else if verify {
if let Err(e) = verify_and_report(p, &merged, &write_results) {
eprintln!("write succeeded; readback verification failed: {e}");
}
} else {
let total_new = write_results.iter().filter(|r| r.is_some()).count();
let w = total_new.to_string().len();
let mut n = 0usize;
for (chart, status) in merged.iter().zip(write_results.iter()) {
if let Some(s) = status {
n += 1;
println!("[{n:0>w$}/{total_new}] {} {s}", chart.name);
}
}
}
} else {
let p = out_path.expect("out_path set for file target");
if cli.verbose && !to_stdout {
for name in &skipped {
println!(" dup: {name}");
}
}
write_file_target(p, out_target, &merged)?;
if !to_stdout {
if existing_count > 0 {
println!(" existing: {existing_count:>6}");
}
println!(" in: {new_input_count:>6}");
println!(" dupes: {dupes:>6}");
println!(" out: {:>6}", merged.len());
println!("wrote {}", p.display());
}
}
Ok(())
}
fn cmd_normalize_inplace(inputs: &[PathBuf]) -> Result<()> {
if inputs.is_empty() {
bail!("at least one input file is required for --normalize");
}
for path in inputs {
let target = Format::from_path(path)
.with_context(|| format!("cannot detect target from '{}'", path.display()))?;
let mut charts = read_file_target(path, target)
.with_context(|| format!("reading {}", path.display()))?;
for chart in &mut charts {
normalize_chart(chart);
}
write_file_target(path, target, &charts)
.with_context(|| format!("writing {}", path.display()))?;
println!("normalised {} charts in {}", charts.len(), path.display());
}
Ok(())
}
fn cmd_clear(provider: WebProvider) -> Result<()> {
let (charts, ids) = provider.fetch_all_with_ids()?;
if charts.is_empty() {
println!("no charts found — nothing to delete.");
return Ok(());
}
eprint!(
"Delete all {} charts from {}? [y/N] ",
charts.len(),
provider.site_display()
);
let _ = std::io::stderr().flush();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("reading confirmation")?;
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
eprintln!("Aborted.");
return Ok(());
}
let total = charts.len();
let w = total.to_string().len();
for (i, (chart, id)) in charts.iter().zip(ids.iter()).enumerate() {
let n = i + 1;
let name: String = chart.name.chars().take(40).collect();
print!("[{n:0>w$}/{total}] {name} ");
let _ = std::io::stdout().flush();
match provider.delete_one(id) {
Ok(()) => println!("deleted"),
Err(e) => println!("[!] {e}"),
}
}
println!("cleared {} charts from {}", total, provider.site_display());
Ok(())
}
fn cmd_consolidate(provider: WebProvider, cli: &Cli) -> Result<()> {
use astrogram::consolidate::group_candidates;
use astrogram::decision_log::{Choice, DecisionLog};
let log_path = cli
.decision_log
.clone()
.unwrap_or_else(default_decision_log_path);
println!("Decision log: {}", log_path.display());
let (charts, ids) = provider
.fetch_all_with_ids()
.with_context(|| format!("fetching charts from {}", provider.site_display()))?;
println!("Fetched {} charts.", charts.len());
let all_groups = group_candidates(&charts);
let groups: Vec<Vec<usize>> = all_groups.into_iter().filter(|g| g.len() > 1).collect();
if groups.is_empty() {
println!("No candidate groups found. Nothing to consolidate.");
return Ok(());
}
println!("Found {} candidate group(s).", groups.len());
let prior = DecisionLog::read_all(&log_path).context("reading decision log")?;
let already_decided: std::collections::HashSet<String> =
prior.iter().map(|r| r.group_id.clone()).collect();
if !already_decided.is_empty() {
println!(
"Resuming: {} group(s) already in decision log.",
already_decided.len()
);
}
let mut log = DecisionLog::open(&log_path).context("opening decision log")?;
let outcome = {
let stdin = std::io::stdin();
let mut stdin_lock = stdin.lock();
let stdout = std::io::stdout();
let mut stdout_lock = stdout.lock();
consolidate_ui::run_loop(
&groups,
&charts,
&ids,
&already_decided,
&mut log,
&mut stdin_lock,
&mut stdout_lock,
)
.context("consolidation loop")?
};
drop(log);
if matches!(outcome, consolidate_ui::RunOutcome::QuitEarly) {
println!("Quit before completion. Decisions so far are in the log.");
}
let all = DecisionLog::read_all(&log_path).context("re-reading decision log")?;
let drops: Vec<String> = all
.iter()
.filter(|r| matches!(r.choice, Choice::Drop) && !r.phenom_id.is_empty())
.map(|r| r.phenom_id.clone())
.collect();
if drops.is_empty() {
println!("No drops to apply.");
return Ok(());
}
eprint!(
"About to delete {} chart(s) from {}. Proceed? [y/N] ",
drops.len(),
provider.site_display(),
);
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("reading confirmation")?;
if !matches!(answer.trim().to_lowercase().as_str(), "y" | "yes") {
eprintln!("Apply aborted. Decisions remain in the log; re-run to resume.");
return Ok(());
}
let total = drops.len();
let mut failed = 0usize;
for (i, id) in drops.iter().enumerate() {
if i > 0 {
std::thread::sleep(std::time::Duration::from_millis(cli.delay));
}
print!("[{:>3}/{}] {id} ", i + 1, total);
let _ = std::io::stdout().flush();
match provider.delete_one(id) {
Ok(()) => println!("deleted"),
Err(e) => {
println!("[!] {e}");
failed += 1;
}
}
}
println!("Deleted {}/{total} chart(s).", total - failed);
Ok(())
}
fn default_decision_log_path() -> PathBuf {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.unwrap_or_else(|| PathBuf::from("."));
base.join("blackmoon").join("luna-decisions.jsonl")
}
fn read_file_target(path: &Path, target: Target) -> Result<Vec<astrogram::chart::Chart>> {
match target {
Target::Luna => bail!("use --from luna rather than passing a file path"),
Target::Astrocom => bail!("use --from astrocom rather than passing a file path"),
Target::Astrotheoros => bail!("use --from astrotheoros rather than passing a file path"),
Target::Json => bail!("JZOD (json) is a write-only format; reading is not supported"),
Target::Raw => bail!("raw is a write-only format; reading is not supported"),
_ => {}
}
let bytes = if path == Path::new("-") {
use std::io::Read as _;
let mut buf = Vec::new();
std::io::stdin().read_to_end(&mut buf)?;
buf
} else {
std::fs::read(path)?
};
astrogram::convert::read_bytes(target, &bytes).map_err(|e| anyhow::anyhow!("{e}"))
}
fn write_bytes_to(path: &Path, data: &[u8]) -> Result<()> {
if path == Path::new("-") {
std::io::stdout().write_all(data)?;
} else {
std::fs::write(path, data)?;
}
Ok(())
}
fn write_file_target(
path: &Path,
target: Target,
charts: &[astrogram::chart::Chart],
) -> Result<()> {
match target {
Target::Aaf => bail!("AAF is a read-only format; choose a writable --to/--output"),
Target::Luna => bail!("use --to luna for writing to LUNA"),
Target::Astrocom => bail!("use --to astrocom for writing to astro.com"),
Target::Astrotheoros => bail!("use --to astrotheoros for writing to astrotheoros.com"),
_ => {}
}
let existing_desc = if target == Target::Sfcht && path != Path::new("-") {
std::fs::read(path)
.ok()
.and_then(|b| astrogram::sfcht::parse_file(&b).ok())
.map(|(hdr, _)| hdr.description)
} else {
None
};
let out = astrogram::convert::write_bytes(target, charts, existing_desc.as_deref())
.map_err(|e| anyhow::anyhow!("{e}"))?;
write_bytes_to(path, &out)
}
fn detect_shell() -> Option<clap_complete::Shell> {
let shell = std::env::var("SHELL").ok()?;
let name = std::path::Path::new(&shell).file_name()?.to_str()?;
name.parse().ok()
}
fn now_secs() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn transcript_summary(m: &[astrogram::transcript::FieldMapping]) -> String {
use astrogram::transcript::FieldStatus::{Dropped, Filled, Preserved, Transformed};
let (mut p, mut t, mut d, mut f) = (0, 0, 0, 0);
for fm in m {
match fm.status {
Preserved => p += 1,
Transformed => t += 1,
Dropped => d += 1,
Filled => f += 1,
astrogram::transcript::FieldStatus::Absent => {}
}
}
format!("{p} preserved, {t} transformed, {d} dropped, {f} filled")
}
fn clip(s: &str, width: usize) -> String {
if s.chars().count() <= width {
s.to_string()
} else {
let kept: String = s.chars().take(width.saturating_sub(1)).collect();
format!("{kept}…")
}
}
fn print_transcript(m: &[astrogram::transcript::FieldMapping]) {
use astrogram::transcript::FieldStatus::{Dropped, Filled, Preserved};
for fm in m {
let glyph = if fm.status == Preserved { "=" } else { "→" };
let from = match fm.status {
Filled => "(filled)".to_string(),
_ => clip(&fm.from, 20),
};
let to = match fm.status {
Dropped => "(dropped)".to_string(),
_ => clip(&fm.to, 20),
};
let note = fm.note.map(|n| format!(" ({n})")).unwrap_or_default();
println!(" {:<18}{:<22}{glyph} {to}{note}", fm.label, from);
}
println!(" → {}", transcript_summary(m));
}
fn verify_and_report(
provider: &WebProvider,
written: &[astrogram::chart::Chart],
write_results: &[Option<String>],
) -> Result<()> {
if astrogram::transcript::has_tied_datetimes(written) {
eprintln!(
"note: some charts share a birth datetime; readback pairing for those is best-effort (input order)"
);
}
let global = provider.fetch_global_settings()?;
let (landed_all, _ids) = provider.fetch_all_with_ids()?;
let pairing = astrogram::transcript::pair_landed(written, &landed_all);
let total_new = write_results.iter().filter(|r| r.is_some()).count();
let w = total_new.to_string().len();
let mut new_idx = 0usize;
let mut verified = 0;
for ((src, maybe_idx), status) in written.iter().zip(pairing).zip(write_results.iter()) {
let header = match status {
Some(s) => {
new_idx += 1;
format!("[{new_idx:0>w$}/{total_new}] {} {s}", src.name)
}
None => src.name.clone(),
};
match maybe_idx {
None => println!("{header}\n not found on readback — skipped"),
Some(i) => {
let mut landed = landed_all[i].clone();
let notes: &[(astrogram::capability::ChartField, &'static str)] =
if let Some(g) = &global {
landed.house_system = g.house_system;
landed.zodiac = g.zodiac;
landed.coordinate_system = g.coordinate_system;
&g.field_notes
} else {
&[]
};
let mappings = astrogram::transcript::diff(src, &landed, notes);
println!("{header}");
print_transcript(&mappings);
verified += 1;
}
}
}
println!(
"verified {}/{} charts (readback from {})",
verified,
written.len(),
provider.site_display()
);
Ok(())
}
fn report_drops(
merged: &[astrogram::chart::Chart],
source_of: &std::collections::HashMap<providers::DatetimeKey, Format>,
sink: Format,
to_stdout: bool,
) -> usize {
use astrogram::capability::lost_fields;
let mut affected: Vec<(&str, Vec<&'static str>)> = Vec::new();
for chart in merged {
let source = source_of
.get(&providers::key(chart))
.copied()
.unwrap_or(sink);
let lost = lost_fields(chart, source, sink);
if !lost.is_empty() {
affected.push((
chart.name.as_str(),
lost.iter().map(|f| f.label()).collect(),
));
}
}
if !affected.is_empty() && !to_stdout {
let sink_name = sink.spec().slug;
let all_lost: std::collections::BTreeSet<&str> = affected
.iter()
.flat_map(|(_, fs)| fs.iter().copied())
.collect();
let lost_list = all_lost.into_iter().collect::<Vec<_>>().join(", ");
println!(
"{sink_name} does not store: {lost_list}. ({} chart(s) affected)",
affected.len()
);
}
affected.len()
}
#[cfg(test)]
mod cookie_import_tests {
use super::*;
#[test]
fn parse_browser_all_returns_grant_all() {
assert_eq!(parse_browser("all"), Ok(GrantArg::All));
}
#[test]
fn parse_browser_firefox_returns_one_firefox() {
assert_eq!(
parse_browser("firefox"),
Ok(GrantArg::One(Browser::Firefox))
);
}
#[test]
fn parse_browser_chrome_returns_one_chrome() {
assert_eq!(parse_browser("chrome"), Ok(GrantArg::One(Browser::Chrome)));
}
#[test]
fn parse_browser_safari_returns_one_safari() {
assert_eq!(parse_browser("safari"), Ok(GrantArg::One(Browser::Safari)));
}
#[test]
fn parse_browser_unknown_returns_err() {
let result = parse_browser("nope");
assert!(result.is_err(), "expected Err for unknown browser 'nope'");
let msg = result.unwrap_err();
assert!(
msg.contains("nope"),
"error should name the unknown value, got: {msg}"
);
}
#[test]
fn parse_browser_all_valid_names_succeed() {
for name in &[
"chrome", "chromium", "brave", "edge", "opera", "vivaldi", "whale", "firefox",
"safari", "all",
] {
assert!(
parse_browser(name).is_ok(),
"parse_browser({name:?}) should succeed"
);
}
}
#[test]
fn grant_choice_none_flag_is_no_grant() {
assert_eq!(grant_choice(&None), GrantChoice::NoGrant);
}
#[test]
fn grant_choice_grant_all_is_all_stores() {
assert_eq!(grant_choice(&Some(GrantArg::All)), GrantChoice::AllStores);
}
#[test]
fn grant_choice_grant_one_chrome_is_one_chrome() {
assert_eq!(
grant_choice(&Some(GrantArg::One(Browser::Chrome))),
GrantChoice::One(Browser::Chrome)
);
}
#[test]
fn grant_choice_grant_one_firefox_is_one_firefox() {
assert_eq!(
grant_choice(&Some(GrantArg::One(Browser::Firefox))),
GrantChoice::One(Browser::Firefox)
);
}
#[test]
fn flag_absent_yields_no_grant() {
let cli = Cli::parse_from(["blackmoon"]);
assert_eq!(grant_choice(&cli.grant_cookie_access), GrantChoice::NoGrant);
}
#[test]
fn bare_flag_yields_all_stores() {
let cli = Cli::parse_from(["blackmoon", "--grant-cookie-access"]);
assert_eq!(
grant_choice(&cli.grant_cookie_access),
GrantChoice::AllStores
);
}
#[test]
fn flag_equals_all_yields_all_stores() {
let cli = Cli::parse_from(["blackmoon", "--grant-cookie-access=all"]);
assert_eq!(
grant_choice(&cli.grant_cookie_access),
GrantChoice::AllStores
);
}
#[test]
fn flag_equals_firefox_yields_one_firefox() {
let cli = Cli::parse_from(["blackmoon", "--grant-cookie-access=firefox"]);
assert_eq!(
grant_choice(&cli.grant_cookie_access),
GrantChoice::One(Browser::Firefox)
);
}
#[test]
fn flag_equals_chrome_yields_one_chrome() {
let cli = Cli::parse_from(["blackmoon", "--grant-cookie-access=chrome"]);
assert_eq!(
grant_choice(&cli.grant_cookie_access),
GrantChoice::One(Browser::Chrome)
);
}
}
#[cfg(test)]
mod provider_tests {
use super::*;
use clap::Parser;
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn clear_cred_env() -> std::sync::MutexGuard<'static, ()> {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
for var in &[
"LUNA_TOKEN",
"ASTROCOM_TOKEN",
"ASTROCOM_USER",
"ASTROCOM_PASS",
"ASTROTHEOROS_TOKEN",
"ASTROTHEOROS_USER",
"ASTROTHEOROS_PASS",
] {
unsafe { std::env::remove_var(var) };
}
guard
}
#[test]
fn resolve_provider_luna_no_creds_bails() {
let _guard = clear_cred_env();
let cli = Cli::parse_from(["blackmoon", "--from", "luna"]);
let err = resolve_provider(Target::Luna, &cli).unwrap_err();
assert!(err.to_string().contains("luna-token"), "unexpected: {err}");
}
#[test]
fn resolve_provider_luna_token_chain_assembled() {
let _guard = clear_cred_env();
let cli = Cli::parse_from(["blackmoon", "--luna-token", "abc123"]);
match resolve_provider(Target::Luna, &cli) {
Ok(_) => {} Err(e) => {
let msg = e.to_string();
assert!(
!msg.contains("luna-token") && !msg.contains("no LUNA credentials"),
"unexpected early bail (no creds?): {msg}"
);
}
}
}
#[test]
fn resolve_provider_astrocom_token_chain_assembled() {
let _guard = clear_cred_env();
let cli = Cli::parse_from(["blackmoon", "--astrocom-token", "test_cid"]);
match resolve_provider(Target::Astrocom, &cli) {
Ok(provider) => {
assert!(
matches!(provider, WebProvider::Astrocom { creds: None, .. }),
"token-only chain must yield creds: None"
);
}
Err(e) => {
let msg = e.to_string();
assert!(
!msg.contains("astrocom-token") && !msg.contains("no astro.com credentials"),
"unexpected early bail (no creds?): {msg}"
);
}
}
}
#[test]
fn resolve_provider_astrocom_half_creds_bails() {
let _guard = clear_cred_env();
let cli = Cli::parse_from(["blackmoon", "--astrocom-user", "user@example.com"]);
let err = resolve_provider(Target::Astrocom, &cli).unwrap_err();
assert!(
err.to_string().contains("astrocom-pass"),
"unexpected: {err}"
);
}
#[test]
fn resolve_provider_astrocom_no_creds_bails() {
let _guard = clear_cred_env();
let cli = Cli::parse_from(["blackmoon"]);
let err = resolve_provider(Target::Astrocom, &cli).unwrap_err();
assert!(
err.to_string().contains("astrocom-token"),
"unexpected: {err}"
);
}
#[test]
fn resolve_provider_astrotheoros_token_attempts_network() {
let _guard = clear_cred_env();
let cli = Cli::parse_from(["blackmoon", "--astrotheoros-token", "jwt:sess:uat"]);
let err = resolve_provider(Target::Astrotheoros, &cli).unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("no astrotheoros.com credentials"),
"unexpected early bail (no creds?): {msg}"
);
}
#[test]
fn resolve_provider_astrotheoros_half_creds_bails() {
let _guard = clear_cred_env();
let cli = Cli::parse_from(["blackmoon", "--astrotheoros-user", "user@example.com"]);
let err = resolve_provider(Target::Astrotheoros, &cli).unwrap_err();
assert!(
err.to_string().contains("astrotheoros-pass"),
"unexpected: {err}"
);
}
#[test]
fn report_drops_counts_affected_charts() {
use astrogram::chart::{
Chart, CoordinateSystem, EventType, HouseSystem, Latitude, Longitude, Zodiac,
};
use std::collections::HashMap;
let mut c = Chart {
name: "Helio Native".into(),
secondary_name: None,
city: Some("c".into()),
region: None,
longitude: Longitude::new(0.0).unwrap(),
latitude: Latitude::new(0.0).unwrap(),
year: 2000,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
tz_offset_hours: 0.0,
tz_abbreviation: None,
is_lmt: false,
event_type: EventType::Unspecified,
source_rating: None,
house_system: HouseSystem::Placidus,
zodiac: Zodiac::Tropical,
coordinate_system: CoordinateSystem::Heliocentric,
sub_charts: vec![],
notes: None,
};
c.notes = Some("n".into());
let mut source_of = HashMap::new();
source_of.insert(providers::key(&c), Target::Sfcht);
let n = report_drops(&[c], &source_of, Target::Astrocom, false);
assert_eq!(n, 1);
}
#[test]
fn consolidate_dispatch_requires_web_target_not_just_luna() {
assert!(is_web_target(Target::Astrocom));
assert!(is_web_target(Target::Astrotheoros));
assert!(is_web_target(Target::Luna));
assert!(!is_web_target(Target::Sfcht));
assert!(!is_web_target(Target::Zeus));
assert!(!is_web_target(Target::Adb));
assert!(!is_web_target(Target::Aaf));
}
}
#[cfg(test)]
mod chain_label_tests {
use super::*;
#[test]
fn source_label_describes_each_chain_position() {
let kinds = [SourceKind::Cookie, SourceKind::Token, SourceKind::Login];
assert_eq!(source_label(&kinds, 0), "browser cookie");
assert_eq!(source_label(&kinds, 1), "token");
assert_eq!(source_label(&kinds, 2), "login");
let two = [SourceKind::Cookie, SourceKind::Login];
assert_eq!(source_label(&two, 1), "login");
}
}
#[cfg(test)]
mod naming_contract {
use super::*;
use astrogram::format::{Auth, FORMATS};
use clap::CommandFactory;
use std::collections::HashSet;
fn long_flags() -> HashSet<String> {
Cli::command()
.get_arguments()
.filter_map(|a| a.get_long().map(String::from))
.collect()
}
fn env_names() -> HashSet<String> {
Cli::command()
.get_arguments()
.filter_map(|a| a.get_env().and_then(|e| e.to_str()).map(String::from))
.collect()
}
#[test]
fn credential_surface_matches_auth() {
let longs = long_flags();
let envs = env_names();
for s in FORMATS {
let upper = s.slug.to_uppercase();
let cred_flags = [
format!("{}-user", s.slug),
format!("{}-pass", s.slug),
format!("{}-token", s.slug),
];
let cred_envs = [
format!("{upper}_USER"),
format!("{upper}_PASS"),
format!("{upper}_TOKEN"),
];
match s.auth {
Auth::None => {
for f in &cred_flags {
assert!(!longs.contains(f), "{} must NOT expose --{f}", s.slug);
}
for e in &cred_envs {
assert!(!envs.contains(e), "{} must NOT expose env {e}", s.slug);
}
}
Auth::Token => {
assert!(
longs.contains(&cred_flags[2]),
"missing flag --{} for {}",
cred_flags[2],
s.slug
);
assert!(
envs.contains(&cred_envs[2]),
"missing env {} for {}",
cred_envs[2],
s.slug
);
for f in &cred_flags[..2] {
assert!(
!longs.contains(f),
"{} must NOT expose --{f} (login deferred)",
s.slug
);
}
for e in &cred_envs[..2] {
assert!(
!envs.contains(e),
"{} must NOT expose env {e} (login deferred)",
s.slug
);
}
}
Auth::LoginOrToken => {
for f in &cred_flags {
assert!(longs.contains(f), "{} must expose --{f}", s.slug);
}
for e in &cred_envs {
assert!(envs.contains(e), "{} must expose env {e}", s.slug);
}
}
}
}
}
}
#[cfg(test)]
mod convert_tests {
use super::*;
#[test]
fn resolve_fill_house_parses_flag() {
use astrogram::chart::HouseSystem;
assert_eq!(
HouseSystem::from_str_slug("placidus"),
Some(HouseSystem::Placidus)
);
assert_eq!(
HouseSystem::from_str_slug("whole-sign"),
Some(HouseSystem::WholeSign)
);
assert!(HouseSystem::from_str_slug("nonsense").is_none());
}
#[test]
fn fills_needed_adb_to_sfcht() {
let f = astrogram::capability::fill_fields(Target::Adb, Target::Sfcht);
assert_eq!(f.len(), 3);
}
#[test]
fn apply_fills_does_not_clobber_sfcht_source_charts() {
use astrogram::capability::ChartField;
use astrogram::chart::{
Chart, CoordinateSystem, EventType, HouseSystem, Latitude, Longitude, Zodiac,
};
use clap::Parser;
use std::collections::HashMap;
let make_chart = |name: &str| Chart {
name: name.into(),
secondary_name: None,
city: Some("c".into()),
region: None,
longitude: Longitude::new(0.0).unwrap(),
latitude: Latitude::new(0.0).unwrap(),
year: 2000,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0,
tz_offset_hours: 0.0,
tz_abbreviation: None,
is_lmt: false,
event_type: EventType::Unspecified,
source_rating: None,
house_system: HouseSystem::WholeSign,
zodiac: Zodiac::Tropical,
coordinate_system: CoordinateSystem::Geocentric,
sub_charts: vec![],
notes: None,
};
let sfcht_chart = make_chart("SFcht Chart");
let adb_chart = make_chart("ADB Chart");
let mut merged = vec![sfcht_chart, adb_chart];
let mut source_of: HashMap<providers::DatetimeKey, Format> = HashMap::new();
source_of.insert(providers::key(&merged[0]), Target::Sfcht);
source_of.insert(providers::key(&merged[1]), Target::Adb);
let fills = vec![
ChartField::HouseSystem,
ChartField::Zodiac,
ChartField::CoordinateSystem,
];
let cli = Cli::parse_from([
"blackmoon",
"--fill-house",
"placidus",
"--fill-zodiac",
"tropical",
"--fill-locus",
"geocentric",
]);
apply_fills(&mut merged, &fills, &source_of, &cli, Target::Sfcht).unwrap();
assert_eq!(
merged[0].house_system,
HouseSystem::WholeSign,
"SFcht source chart must keep its genuine house system"
);
assert_eq!(
merged[1].house_system,
HouseSystem::Placidus,
"ADB source chart must receive the filled house system"
);
}
#[test]
fn transcript_summary_counts_statuses() {
use astrogram::transcript::{FieldMapping, FieldStatus};
let m = vec![
FieldMapping {
label: "name",
from: "a".into(),
to: "a".into(),
status: FieldStatus::Preserved,
note: None,
},
FieldMapping {
label: "house system",
from: "alcabitius".into(),
to: "placidus".into(),
status: FieldStatus::Transformed,
note: Some("global setting"),
},
FieldMapping {
label: "notes",
from: "x".into(),
to: String::new(),
status: FieldStatus::Dropped,
note: None,
},
];
let s = transcript_summary(&m);
assert_eq!(s, "1 preserved, 1 transformed, 1 dropped, 0 filled");
}
}