arcsec 0.1.1

Astrometric plate solver: find where a telescope was pointing from an image
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
//! `arcsec catalog` — install and manage star catalogues.
//!
//! arcsec needs a star catalogue to solve anything, and a *photometric* catalogue to
//! do colour calibration. Both live behind download pages that are easy to get wrong,
//! so this puts them one command away and in one place:
//!
//! ```text
//! arcsec catalog list                  # what exists, what is installed
//! arcsec catalog recommend --fov 1.5   # what this rig needs
//! arcsec catalog install d50 v05       # fetch and unpack
//! arcsec catalog path                  # where they went
//! ```
//!
//! Everything lands in a per-platform data directory (see [`default_dir`]) which the
//! solver reads by default, so `-d` is only needed to override it.

mod fetch;
mod registry;

use std::ffi::OsString;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};

pub use registry::{ASTAP_EXTS, Files, REGISTRY, is_installed};
use registry::{
    Archive, Entry, Purpose, astap_file_count, expected_file_count, files_of, find, installed_size,
    is_complete, loose_files,
};

use crate::{cli, image_io};

/// Parse and run `arcsec catalog <subcommand>`; returns the process exit code.
///
/// `args` starts at `catalog` itself, i.e. the command line without `arcsec`.
pub fn run(args: impl IntoIterator<Item = OsString>) -> i32 {
    let m = cli::catalog_command().get_matches_from(args);

    let dir = m
        .get_one::<PathBuf>("dir")
        .cloned()
        .unwrap_or_else(default_dir);

    let names = |sm: &clap::ArgMatches| -> Vec<String> {
        sm.get_many::<String>("names")
            .map_or_else(Vec::new, |v| v.cloned().collect())
    };

    let result = match m.subcommand() {
        Some(("list", _)) => {
            cmd_list(&dir);
            Ok(())
        }
        Some(("path", _)) => {
            println!("{}", dir.display());
            Ok(())
        }
        Some(("recommend", sm)) => {
            let fov = match (sm.get_one::<f64>("fov"), sm.get_one::<PathBuf>("like")) {
                (Some(f), _) => Some(*f),
                (None, Some(img)) => image_io::read_pixel_scale(img).and_then(|ps| {
                    image_io::read_dimensions(img).map(|(w, h)| ps * f64::from(w.max(h)) / 3600.0)
                }),
                _ => None,
            };
            match fov {
                Some(f) if f > 0.0 => {
                    cmd_recommend(&dir, f, sm.get_flag("photometry"));
                    Ok(())
                }
                _ => Err(
                    "give --fov <degrees>, or --like <image> with FOCALLEN and XPIXSZ in its header"
                        .to_string(),
                ),
            }
        }
        Some(("install", sm)) => {
            cmd_install(&dir, &names(sm), sm.get_flag("yes"), sm.get_flag("keep"))
        }
        Some(("remove", sm)) => cmd_remove(&dir, &names(sm), sm.get_flag("yes")),
        Some(("verify", _)) => cmd_verify(&dir),
        _ => Err("unknown subcommand".to_string()),
    };

    match result {
        Ok(()) => 0,
        Err(e) => {
            eprintln!("Error: {e}");
            1
        }
    }
}

// ── Install location ────────────────────────────────────────────────────────────

/// Where catalogues are kept, in priority order:
///
/// 1. `$ARCSEC_CATALOG_DIR`, if set — for people who keep them on another disk.
/// 2. `$XDG_DATA_HOME/arcsec/catalogs` on Linux, or the platform equivalent:
///    `~/Library/Application Support/arcsec/catalogs` on macOS,
///    `%LOCALAPPDATA%\arcsec\catalogs` on Windows.
/// 3. `~/.arcsec/catalogs` if the home directory cannot be resolved any other way.
///
/// The point is that a user who runs `arcsec catalog install d50` never has to know
/// this path, and the solver looks here without being told.
pub fn default_dir() -> PathBuf {
    default_dir_from(|k| std::env::var(k).ok())
}

/// [`default_dir`] with the environment supplied by `var`, so it can be tested
/// without mutating the real process environment.
fn default_dir_from(var: impl Fn(&str) -> Option<String>) -> PathBuf {
    let get = |k: &str| var(k).filter(|v| !v.is_empty()).map(PathBuf::from);

    if let Some(p) = get("ARCSEC_CATALOG_DIR") {
        return p;
    }

    let platform = if cfg!(target_os = "windows") {
        get("LOCALAPPDATA").map(|p| p.join("arcsec").join("catalogs"))
    } else if cfg!(target_os = "macos") {
        get("HOME").map(|h| {
            h.join("Library")
                .join("Application Support")
                .join("arcsec")
                .join("catalogs")
        })
    } else {
        get("XDG_DATA_HOME")
            .map(|p| p.join("arcsec").join("catalogs"))
            .or_else(|| {
                get("HOME").map(|h| {
                    h.join(".local")
                        .join("share")
                        .join("arcsec")
                        .join("catalogs")
                })
            })
    };

    platform.unwrap_or_else(|| {
        get("HOME").or_else(|| get("USERPROFILE")).map_or_else(
            || PathBuf::from("catalogs"),
            |h| h.join(".arcsec").join("catalogs"),
        )
    })
}

/// Bytes, in decimal units, to one decimal place: `901.3 MB`.
pub fn human(bytes: u64) -> String {
    const U: [&str; 5] = ["B", "kB", "MB", "GB", "TB"];
    let mut v = bytes as f64;
    let mut i = 0;
    while v >= 1000.0 && i < U.len() - 1 {
        v /= 1000.0;
        i += 1;
    }
    if i == 0 {
        format!("{bytes} {}", U[i])
    } else {
        format!("{v:.1} {}", U[i])
    }
}

/// `"  [installed]"` if `e` is in `dir`, else nothing.
fn installed_tag(dir: &Path, e: &Entry) -> &'static str {
    if is_installed(dir, e) {
        "  [installed]"
    } else {
        ""
    }
}

// ── Commands ────────────────────────────────────────────────────────────────────

fn cmd_list(dir: &Path) {
    println!("Catalogue directory: {}", dir.display());
    println!("  (override with --dir, or the ARCSEC_CATALOG_DIR environment variable)\n");
    println!(
        "{:<11} {:<11} {:>9}  {:<13} STATUS",
        "NAME", "PURPOSE", "DOWNLOAD", "FIELDS"
    );
    for e in REGISTRY {
        let fov = e
            .fov
            .map_or_else(|| "—".to_string(), |(lo, hi)| format!("{lo}°–{hi}°"));
        let status = if is_installed(dir, e) {
            let sz = installed_size(dir, e);
            if sz > 0 {
                format!("installed ({})", human(sz))
            } else {
                "installed".to_string()
            }
        } else {
            "not installed".to_string()
        };
        println!(
            "{:<11} {:<11} {:>9}  {fov:<13} {status}",
            e.id,
            e.purpose.label(),
            human(e.bytes),
        );
    }
    println!("\nDescriptions:");
    for e in REGISTRY {
        println!("  {:<11} {}", e.id, e.desc);
    }
}

/// Suggest catalogues for a field size.
fn cmd_recommend(dir: &Path, fov_deg: f64, want_photometry: bool) {
    println!("For a {fov_deg:.2}° field:\n");
    let pick = |purpose: Purpose| -> Option<&'static Entry> {
        // Prefer the smallest download whose range covers the field, so the advice
        // does not push a gigabyte on someone who does not need it.
        REGISTRY
            .iter()
            .filter(|e| e.purpose == purpose)
            .filter(|e| matches!(e.fov, Some((lo, hi)) if fov_deg >= lo && fov_deg <= hi))
            .min_by_key(|e| e.bytes)
    };

    match pick(Purpose::Solving) {
        Some(e) => println!(
            "  solving      {:<10} {:>9}  {}{}",
            e.id,
            human(e.bytes),
            e.desc,
            installed_tag(dir, e)
        ),
        None => println!("  solving      no catalogue covers this field size"),
    }
    if want_photometry {
        match pick(Purpose::Photometry) {
            Some(e) => println!(
                "  photometry   {:<10} {:>9}  {}{}",
                e.id,
                human(e.bytes),
                e.desc,
                installed_tag(dir, e)
            ),
            None => println!("  photometry   no catalogue covers this field size"),
        }
    }
    match pick(Purpose::BlindIndex) {
        Some(e) => println!(
            "  blind        {:<10} {:>9}  optional: solve with no position hint{}",
            e.id,
            human(e.bytes),
            installed_tag(dir, e)
        ),
        None => println!("  blind        no index set covers this field size"),
    }

    let ids: Vec<&str> = [
        pick(Purpose::Solving),
        want_photometry.then(|| pick(Purpose::Photometry)).flatten(),
    ]
    .into_iter()
    .flatten()
    .filter(|e| !is_installed(dir, e))
    .map(|e| e.id)
    .collect();
    if !ids.is_empty() {
        println!("\n  arcsec catalog install {}", ids.join(" "));
    }
}

/// Ask on stderr and read a yes/no answer from stdin; anything but `y`/`yes`,
/// including end of input, is a no.
fn confirm() -> bool {
    eprint!("Continue? [y/N] ");
    let _ = std::io::stderr().flush();
    let mut line = String::new();
    std::io::stdin().read_line(&mut line).is_ok()
        && matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}

fn cmd_remove(dir: &Path, ids: &[String], assume_yes: bool) -> Result<(), String> {
    let mut wanted = Vec::new();
    for id in ids {
        let e = find(id)
            .ok_or_else(|| format!("unknown catalogue '{id}' (try: arcsec catalog list)"))?;
        if !wanted.iter().any(|w: &&Entry| w.id == e.id) {
            wanted.push(e);
        }
    }

    // The catalogue directory may be shared with ASTAP (docs/catalogues.md §6), in
    // which case these are ASTAP's files too, so say exactly what will go first.
    println!("Removing from {}\n", dir.display());
    for e in &wanted {
        let files = files_of(dir, e);
        let bytes: u64 = files
            .iter()
            .filter_map(|p| fs::metadata(p).ok())
            .map(|m| m.len())
            .sum();
        println!(
            "  {:<11} {:>5} files  {:>9}",
            e.id,
            files.len(),
            human(bytes)
        );
    }
    println!();
    if !assume_yes && !confirm() {
        println!("Cancelled.");
        return Ok(());
    }

    for e in wanted {
        let files = files_of(dir, e);
        let mut freed = 0u64;
        for p in &files {
            freed += fs::metadata(p).map_or(0, |m| m.len());
            fs::remove_file(p).map_err(|err| format!("{}: {err}", p.display()))?;
        }
        println!(
            "removed {}: {} files, {} freed",
            e.id,
            files.len(),
            human(freed)
        );
    }
    Ok(())
}

/// Check that every installed catalogue looks structurally sound.
fn cmd_verify(dir: &Path) -> Result<(), String> {
    let mut problems = 0;
    let mut checked = 0;
    for e in REGISTRY {
        if !is_installed(dir, e) {
            continue;
        }
        checked += 1;
        let files = files_of(dir, e);
        let mut bad = Vec::new();

        for p in &files {
            match fs::metadata(p) {
                // Every format has at least a 110-byte header (or a FITS block).
                Ok(m) if m.len() < 120 => bad.push(format!("{} is truncated", p.display())),
                Err(err) => bad.push(format!("{}: {err}", p.display())),
                _ => {}
            }
        }

        // The file count is fixed: by the grid for an ASTAP database, by the set
        // for downloaded indexes.
        if let Some(want) = astap_file_count(dir, e).or_else(|| expected_file_count(e))
            && files.len() != want
        {
            bad.push(format!("expected {want} files, found {}", files.len()));
        }

        if bad.is_empty() {
            println!(
                "  {:<11} ok  ({} files, {})",
                e.id,
                files.len(),
                human(installed_size(dir, e))
            );
        } else {
            problems += bad.len();
            println!("  {:<11} PROBLEMS:", e.id);
            for b in bad {
                println!("      {b}");
            }
        }
    }
    if checked == 0 {
        println!("No catalogues installed in {}", dir.display());
        return Ok(());
    }
    if problems > 0 {
        return Err(format!(
            "{problems} problem(s) found; re-run `arcsec catalog install <name>`"
        ));
    }
    Ok(())
}

fn cmd_install(dir: &Path, ids: &[String], assume_yes: bool, keep: bool) -> Result<(), String> {
    let mut wanted: Vec<&'static Entry> = Vec::new();
    for id in ids {
        let e = find(id).ok_or_else(|| {
            format!("unknown catalogue '{id}'. Run `arcsec catalog list` to see the options.")
        })?;
        if is_complete(dir, e) {
            println!("{}: already installed, skipping", e.id);
            continue;
        }
        if !wanted.iter().any(|w| w.id == e.id) {
            wanted.push(e);
        }
    }
    if wanted.is_empty() {
        return Ok(());
    }

    let total: u64 = wanted.iter().map(|e| e.bytes).sum();
    println!("Installing into {}\n", dir.display());
    for e in &wanted {
        println!("  {:<11} {:>9}  {}", e.id, human(e.bytes), e.desc);
    }
    println!("\nTotal download: {}", human(total));
    if !assume_yes && !confirm() {
        println!("Cancelled.");
        return Ok(());
    }

    fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;

    for e in wanted {
        println!("\n{} — {}", e.id, e.desc);
        match e.archive {
            Archive::Loose => install_loose(dir, e)?,
            Archive::Zip | Archive::Deb => install_archive(dir, e, keep)?,
        }
        if is_installed(dir, e) {
            println!("  {} installed ({})", e.id, human(installed_size(dir, e)));
        } else {
            return Err(format!(
                "{}: extraction finished but no catalogue files appeared in {}",
                e.id,
                dir.display()
            ));
        }
    }
    println!("\nDone. The solver uses {} by default.", dir.display());
    Ok(())
}

/// Download each file of a `Loose` set that is not already present.
///
/// A file that fails is reported and the rest continue; the set as a whole then
/// fails, so a script sees it, and a re-run fetches only what is missing.
fn install_loose(dir: &Path, e: &Entry) -> Result<(), String> {
    let files = loose_files(e);
    let mut done = 0;
    for (i, (url, name)) in files.iter().enumerate() {
        let dest = dir.join(name);
        if dest.is_file() {
            done += 1;
            continue;
        }
        let label = format!("{} [{}/{}] {name}", e.id, i + 1, files.len());
        match fetch::download(url, &dest, &label) {
            Ok(()) => done += 1,
            Err(err) => eprintln!("  warning: {err}"),
        }
    }
    println!("  {done}/{} index files present", files.len());
    if done < files.len() {
        return Err(format!(
            "{}: {} of {} files could not be downloaded; run the install again to fetch them",
            e.id,
            files.len() - done,
            files.len()
        ));
    }
    Ok(())
}

/// Download a `.zip` or `.deb` and unpack its catalogue files into `dir`.
///
/// Files are extracted into a staging directory first and moved into place only
/// once the whole archive has unpacked. An install interrupted part way therefore
/// leaves nothing that looks installed — `is_installed` probes a single file, so a
/// half-extracted database would otherwise be reported as present, and skipped by
/// the next install.
fn install_archive(dir: &Path, e: &Entry, keep: bool) -> Result<(), String> {
    let ext = if e.archive == Archive::Zip {
        "zip"
    } else {
        "deb"
    };
    let tmp = dir.join(format!(".{}-download.{ext}", e.id));
    if !tmp.is_file() {
        fetch::download(e.url, &tmp, e.id)?;
    }

    let staging = dir.join(format!(".{}-staging", e.id));
    let _ = fs::remove_dir_all(&staging);
    fs::create_dir_all(&staging).map_err(|err| format!("{}: {err}", staging.display()))?;

    eprintln!("  extracting ...");
    let wanted = |name: &str| e.files.owns(name);
    let extracted = if e.archive == Archive::Zip {
        fetch::extract_zip(&tmp, &staging, &wanted)
    } else {
        fetch::extract_deb(&tmp, &staging, &wanted)
    };
    let moved = extracted.and_then(|n| {
        for entry in
            fs::read_dir(&staging).map_err(|err| format!("{}: {err}", staging.display()))?
        {
            let from = entry.map_err(|err| err.to_string())?.path();
            let Some(name) = from.file_name() else {
                continue;
            };
            let to = dir.join(name);
            fs::rename(&from, &to).map_err(|err| format!("{}: {err}", to.display()))?;
        }
        Ok(n)
    });
    let _ = fs::remove_dir_all(&staging);
    let n = moved.map_err(|err| {
        format!(
            "{err}\n  (the archive is kept at {}; delete it to download afresh)",
            tmp.display()
        )
    })?;

    if keep {
        println!("  kept archive at {}", tmp.display());
    } else {
        let _ = fs::remove_file(&tmp);
    }
    println!("  extracted {n} files");
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_dir_is_namespaced() {
        // Deliberately not asserting the exact path: it is platform dependent.
        let d = default_dir();
        assert!(
            d.to_string_lossy().contains("arcsec"),
            "catalogue dir should be namespaced: {}",
            d.display()
        );
    }

    #[test]
    fn env_override_wins() {
        let env = |k: &str| match k {
            "ARCSEC_CATALOG_DIR" => Some("/data/catalogs".to_string()),
            "HOME" => Some("/home/u".to_string()),
            _ => None,
        };
        assert_eq!(default_dir_from(env), PathBuf::from("/data/catalogs"));
    }

    #[test]
    fn empty_variables_count_as_unset() {
        let env = |k: &str| match k {
            "ARCSEC_CATALOG_DIR" | "XDG_DATA_HOME" | "LOCALAPPDATA" => Some(String::new()),
            "HOME" => Some("/home/u".to_string()),
            _ => None,
        };
        let d = default_dir_from(env);
        assert!(d.starts_with("/home/u"), "got {}", d.display());
        assert!(d.ends_with("catalogs"));
    }

    #[test]
    fn no_home_at_all_still_yields_a_path() {
        assert_eq!(default_dir_from(|_| None), PathBuf::from("catalogs"));
    }

    #[test]
    fn human_sizes_read_sensibly() {
        assert_eq!(human(500), "500 B");
        assert_eq!(human(1_500_000), "1.5 MB");
        assert_eq!(human(901_300_000), "901.3 MB");
        assert_eq!(human(1_213_400_000), "1.2 GB");
    }
}