persona-wire-core 0.2.0

persona-wire core: Domain (Graph + Specification + Compute + Constraint + AutoVersion + CRUD) + Application (NamedProjection registry, Use Case) + Infrastructure (SQLite storage, Rendering adapter). Transport-agnostic.
Documentation
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
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
//! Layer 6 Adapter (SoT) — concept-doc §3 Layer 6 + §5 #3 / §P3b の前倒し land。
//!
//! 各 wiring entry node の `metadata.source_uri` を scheme 別に parse + fresh fetch して
//! `serde_json::Value` で返す。 wire は data 本体を持たず、 Adapter が render 時に
//! SoT (mini-app / file / outline / ...) から都度 fetch する。
//!
//! 現状 land 済 scheme:
//! - `mini-app://<table_name>[?scope=user|<project-name>&root=<dir>&alias=<name>&<k>=<v>*&limit=<n>]`
//!   — mini-app-core SDK 経由で table を open + list / QueryAlias 実行。 reserved query keys
//!   は `scope` (= `user` → `AliasScope::User` / 任意 project identifier → `AliasScope::Project`、
//!   省略時 = global storage (User scope) → per-table `_aliases` fallback)、 `root` (= 物理 dir
//!   上書き、 scope=<project-name> 時は必須、 省略時 = `$MINI_APP_USER_DIR` or `~/.mini-app/`)、
//!   `alias` (= global `_global.db` 内 `_global_aliases` (mini-app v0.12.1+ default) +
//!   legacy per-table `_aliases` (backward compat) 双方解決対応、 Multi / Pattern source /
//!   aggregator は P3b carry)、 `limit` (= list 上限 override)。 render / parse / list は SDK
//!   (`mini_app_core::alias_run::execute_alias_run`) に完全委譲、 wire は filter / MiniJinja /
//!   ListFilter 意味論を一切解釈しない (= reframe-gate §1 architecture 軸、 Resource × 取り出し方
//!   の連携 layer 役)。
//! - `file://<absolute-or-tilde-path>` — std::fs::read で raw 字面を取得 (json/toml は将来
//!   parse 拡張、 現状は string として返す)。
//!
//! outline / persona-pack / journal scheme は P3b carry。

use std::path::PathBuf;

use crate::domain::error::{WireError, WireResult};

/// Adapter trait. async fn を持つので async-trait の代わりに `Pin<Box<Future>>`
/// 返却で表現 (wire-core を async-trait dep から守るため)。
#[allow(async_fn_in_trait)]
pub trait Adapter: Send + Sync {
    /// `source_uri` を scheme 別に解釈し、 fresh data を `serde_json::Value` で返す。
    async fn fetch(&self, source_uri: &str) -> WireResult<serde_json::Value>;
}

/// `mini-app://` URI から抽出した取り出し指示。 wire scope 内で意味解釈する 4 key
/// (`scope` / `root` / `alias` / `limit`) は専用 field に、 残り query key 全てが `params`
/// (json object) に集約される。 wire は params 値の型 / 意味を一切解釈せず、 mini-app
/// 側の MiniJinja + ListFilter に渡すだけ。
///
/// 内部 URI parse 結果の中間 data structure、 module 外には漏らさない (= 公開 surface
/// 最小化、 rust idiom)。 caller surface は `fetch_via_adapter` (free fn) のみ。
#[derive(Debug, Clone)]
struct MiniAppUriSpec {
    /// `mini-app://<table>` の table 部分。
    table: String,
    /// `?scope=user|<project-name>` の identifier。 `"user"` は `AliasScope::User` に
    /// mapping、 それ以外の任意 string は `AliasScope::Project` に mapping (project name は
    /// `root` field で物理 dir を別途明示する前提)。 不在時は backward compat (legacy
    /// fallback path)。
    scope: Option<String>,
    /// `?root=<dir>` の物理 dir override。 scope=<project-name> 時は必須、 scope=user /
    /// 省略時は任意 (不在時は `$MINI_APP_USER_DIR` or `~/.mini-app/`)。
    root: Option<PathBuf>,
    /// `?alias=<name>` の name 部分。 不在時は `None` で list-all 互換 path。
    alias: Option<String>,
    /// `?limit=<n>` の数値。 invalid 値は parse error。
    limit: Option<u32>,
    /// `scope` / `root` / `alias` / `limit` 以外の query key を全部 string value で集めた
    /// object。 MiniJinja render に渡す。
    params: serde_json::Value,
}

/// `mini-app://<table>[?scope=<s>&root=<dir>&alias=<name>&k=v*&limit=<n>]` を parse する
/// 内部 helper。 scheme prefix は呼び出し側で剥がす前提で受ける (= `<table>[?query]` の
/// rest だけ受け取る)。 query 不在時は全 field が `None` で table only spec を返す。
///
/// scope=<project-name> (= scope != "user" かつ Some) で `root` 不在時は parse error
/// (= 物理 dir 解決不能、 fail-fast)。 scope=user / scope 不在時は `root` 任意。
fn parse_mini_app_uri(rest: &str) -> WireResult<MiniAppUriSpec> {
    let full_uri = format!("mini-app://{rest}");
    let parsed = url::Url::parse(&full_uri)
        .map_err(|e| WireError::Storage(format!("mini-app adapter: bad uri: {full_uri}: {e}")))?;

    // host_str() = table 名 (e.g. `mini-app://mailbox` の `mailbox`)
    // url crate は `mini-app://` を non-special scheme として host を持つ form で扱う。
    let table = parsed
        .host_str()
        .ok_or_else(|| {
            WireError::Storage(format!("mini-app adapter: missing table in {full_uri}"))
        })?
        .to_string();

    let mut scope: Option<String> = None;
    let mut root: Option<PathBuf> = None;
    let mut alias: Option<String> = None;
    let mut limit: Option<u32> = None;
    let mut params_map = serde_json::Map::new();
    for (k, v) in parsed.query_pairs() {
        match k.as_ref() {
            "scope" => scope = Some(v.into_owned()),
            "root" => root = Some(resolve_root_path(v.as_ref())?),
            "alias" => alias = Some(v.into_owned()),
            "limit" => {
                let n: u32 = v.parse().map_err(|e| {
                    WireError::Storage(format!(
                        "mini-app adapter: invalid limit '{v}' in {full_uri}: {e}"
                    ))
                })?;
                limit = Some(n);
            }
            _ => {
                params_map.insert(k.into_owned(), serde_json::Value::String(v.into_owned()));
            }
        }
    }

    // scope=<project-name> 時は root 必須 (= 物理 dir 解決不能を fail-fast 化)
    if let Some(s) = scope.as_deref() {
        if s != "user" && root.is_none() {
            return Err(WireError::Storage(format!(
                "mini-app adapter: scope='{s}' requires ?root=<dir> in {full_uri}"
            )));
        }
    }

    Ok(MiniAppUriSpec {
        table,
        scope,
        root,
        alias,
        limit,
        params: serde_json::Value::Object(params_map),
    })
}

/// `?root=<dir>` の値を `PathBuf` に解決。 `~/...` は HOME 展開、 それ以外は as-is。
fn resolve_root_path(raw: &str) -> WireResult<PathBuf> {
    if let Some(rest) = raw.strip_prefix("~/") {
        let home = std::env::var("HOME")
            .map_err(|_| WireError::Storage("mini-app adapter: HOME unset".to_string()))?;
        Ok(PathBuf::from(home).join(rest))
    } else {
        Ok(PathBuf::from(raw))
    }
}

/// Dispatch helper: `source_uri` の scheme prefix を見て対応 Adapter を呼ぶ。
/// `wire_init` use case から 1 行で呼べる shim。
pub async fn fetch_via_adapter(source_uri: &str) -> WireResult<serde_json::Value> {
    if let Some(rest) = source_uri.strip_prefix("mini-app://") {
        let spec = parse_mini_app_uri(rest)?;
        if spec.alias.is_some() {
            MiniAppAdapter.fetch_via_alias(&spec).await
        } else {
            // alias 不在 = 既存 list-all 互換 path (limit 指定があれば反映)
            MiniAppAdapter.fetch_table_via_spec(&spec).await
        }
    } else if let Some(rest) = source_uri.strip_prefix("file://") {
        FileAdapter.fetch_file(rest).await
    } else if let Some(rest) = source_uri.strip_prefix("file:") {
        // `file:~/...` style (no `//`)
        FileAdapter.fetch_file(rest).await
    } else {
        Err(WireError::Storage(format!(
            "adapter: unsupported source_uri scheme: {source_uri}"
        )))
    }
}

// ---- mini-app adapter (SDK 経由) ----

pub struct MiniAppAdapter;

impl MiniAppAdapter {
    /// `mini-app://<table_name>` の table_name 部分を受けて、 default user scope の
    /// `~/.mini-app/<table>/<table>.db` + `schema.yaml` を open + list all rows。
    /// 互換 surface (test / 外部 caller の利便性のため public 維持)。
    pub async fn fetch_table(&self, table_name: &str) -> WireResult<serde_json::Value> {
        let spec = MiniAppUriSpec {
            table: table_name.to_string(),
            scope: None,
            root: None,
            alias: None,
            limit: None,
            params: serde_json::Value::Object(Default::default()),
        };
        self.fetch_table_via_spec(&spec).await
    }

    /// spec 経由 list-all path。 `?scope=` / `?root=` / `?limit=` を尊重する。
    /// 内部 helper、 公開 surface は `fetch_via_adapter` / `Adapter::fetch` 経由。
    /// `limit` 不在時は従来通り 1000 上限 (list default 100 / max 1000)。
    async fn fetch_table_via_spec(&self, spec: &MiniAppUriSpec) -> WireResult<serde_json::Value> {
        let (_db_path, store) =
            open_mini_app_store(&spec.table, spec.scope.as_deref(), spec.root.as_deref()).await?;
        let effective_limit = spec.limit.or(Some(1000));
        let rows = store
            .list(effective_limit, None, None)
            .await
            .map_err(|e| WireError::Storage(format!("mini-app adapter: list: {e}")))?;

        let json_rows: Vec<serde_json::Value> = rows
            .into_iter()
            .map(|r| {
                serde_json::json!({
                    "id": r.id,
                    "data": r.data,
                    "created_at": r.created_at,
                    "updated_at": r.updated_at,
                })
            })
            .collect();

        Ok(serde_json::json!({
            "scheme": "mini-app",
            "table": spec.table,
            "count": json_rows.len(),
            "rows": json_rows,
        }))
    }

    /// `mini-app://<table>?alias=<name>[&scope=<s>&root=<dir>&k=v]*[&limit=<n>]` を受けて、
    /// mini-app QueryAlias 機能を叩いて filter 済 rows を取得する。
    /// render + parse + list は SDK (mini-app-core v0.12+ `execute_alias_run`) に
    /// 完全委譲、 wire 側に MiniJinja / ListFilter / aggregator 認識は持ち込まない。
    ///
    /// alias storage 解決経路 (issue 8904d808 fix、 mini-app v0.12.1 + 整合):
    /// - `scope=user`           → `_global.db` (User scope) のみ叩く (hard fail on miss)
    /// - `scope=<project-name>` → `_global.db` (Project scope = `?root=<dir>`) のみ叩く (hard fail on miss)
    /// - `scope=None` (legacy)  → `_global.db` (User scope) → per-table `_aliases` fallback
    ///   (backward compat: 旧 per-table 経由 alias 経路を温存)
    ///
    /// wire scope: Single source + 非 aggregator alias のみ対応。 Multi / Pattern source / aggregator は
    /// P3b carry (= clear error message で hard fail、 SDK 側で同 carry path を NotImpl 化済)。
    ///
    /// 内部 helper、 公開 surface は `fetch_via_adapter` / `Adapter::fetch` 経由。
    ///
    /// 流れ:
    /// 1. `GlobalAliasStorage::open` (project_dir / user_dir 決定済) + scope 軸別 alias 取得、
    ///    miss + scope=None 時のみ per-table `store.alias_get(name)` fallback
    /// 2. Single source + 非 aggregator 軸の wire scope check
    /// 3. per-table `Store::open` + schema load (TableRegistry 構築用)
    /// 4. `TableRegistry::from_entries` で 1 table 分の registry を組み立て
    /// 5. `execute_alias_run(...)` SDK 1 call で render + parse + list を完全委譲
    /// 6. `AliasRunValue::Rows` を取り出して既存戻り値 shape に整形
    async fn fetch_via_alias(&self, spec: &MiniAppUriSpec) -> WireResult<serde_json::Value> {
        let alias_name = spec
            .alias
            .as_deref()
            .ok_or_else(|| WireError::Storage("mini-app adapter: alias key missing".to_string()))?;

        // Step 1: alias record 取得 (global-first、 scope=None 時のみ per-table fallback)
        let global_rec = resolve_alias_record(
            &spec.table,
            spec.scope.as_deref(),
            spec.root.as_deref(),
            alias_name,
        )
        .await?;

        // Step 2: wire scope check (Single source + 非 aggregator のみ対応、 残りは P3b carry)
        if global_rec.aggregator.is_some() {
            return Err(WireError::Storage(format!(
                "mini-app adapter: alias '{alias_name}' has aggregator — wire scope 外 (P3b carry)"
            )));
        }
        match &global_rec.sources {
            mini_app_core::aggregator::SourceSpec::Single(_) => {}
            mini_app_core::aggregator::SourceSpec::Multi(_)
            | mini_app_core::aggregator::SourceSpec::Pattern(_) => {
                return Err(WireError::Storage(format!(
                    "mini-app adapter: alias '{alias_name}' has Multi / Pattern source — \
                     wire scope 外 (P3b carry)"
                )));
            }
        }

        // Step 3: per-table store + schema open (TableRegistry 構築用)
        let (db_path, store) =
            open_mini_app_store(&spec.table, spec.scope.as_deref(), spec.root.as_deref()).await?;

        // Step 4: schema 再取得 + 1-table registry 組み立て
        let schema_path = db_path
            .parent()
            .ok_or_else(|| {
                WireError::Storage(format!(
                    "mini-app adapter: cannot resolve schema dir from {}",
                    db_path.display()
                ))
            })?
            .join("schema.yaml");
        let schema = mini_app_core::schema::load_from_path(&schema_path)
            .map_err(|e| WireError::Storage(format!("mini-app adapter: schema load: {e}")))?;
        let mut entries = std::collections::HashMap::new();
        entries.insert(
            spec.table.clone(),
            mini_app_core::registry::TableEntry {
                store: std::sync::Arc::new(store),
                schema: std::sync::Arc::new(schema),
                schema_path: std::sync::Arc::new(schema_path),
            },
        );
        let registry =
            mini_app_core::registry::TableRegistry::from_entries(entries, Some(spec.table.clone()));

        // Step 5: SDK execute_alias_run 1 call (= render + parse + list を SDK に完全委譲)
        // params は wire side が URI query から組んだ json object (alias / limit 除外済)。
        let value = mini_app_core::alias_run::execute_alias_run(
            &registry,
            global_rec,
            Some(spec.params.clone()),
            Some(&spec.table),
            spec.limit,
            None,
            None,
        )
        .await
        .map_err(|e| {
            WireError::Storage(format!(
                "mini-app adapter: alias '{alias_name}' execute_alias_run failed: {e}"
            ))
        })?;

        // Step 6: AliasRunValue::Rows を取り出して既存戻り値 shape に整形。
        // wire は plain Rows path 専用 (Step 2 wire scope check で aggregator=None 強制済)、
        // Aggregate variant は P3b carry (defensive で error 化)。
        let rows = match value {
            mini_app_core::alias_run::AliasRunValue::Rows(r) => r,
            mini_app_core::alias_run::AliasRunValue::Aggregate(_) => {
                return Err(WireError::Storage(format!(
                    "mini-app adapter: alias '{alias_name}' returned Aggregate variant — \
                     wire scope 外 (P3b carry)"
                )));
            }
        };

        let json_rows: Vec<serde_json::Value> = rows
            .into_iter()
            .map(|r| {
                serde_json::json!({
                    "id": r.id,
                    "data": r.data,
                    "created_at": r.created_at,
                    "updated_at": r.updated_at,
                })
            })
            .collect();

        Ok(serde_json::json!({
            "scheme": "mini-app",
            "table": spec.table,
            "alias": alias_name,
            "count": json_rows.len(),
            "rows": json_rows,
        }))
    }
}

impl Adapter for MiniAppAdapter {
    async fn fetch(&self, source_uri: &str) -> WireResult<serde_json::Value> {
        let rest = source_uri.strip_prefix("mini-app://").ok_or_else(|| {
            WireError::Storage(format!("mini-app adapter: bad uri: {source_uri}"))
        })?;
        let spec = parse_mini_app_uri(rest)?;
        if spec.alias.is_some() {
            self.fetch_via_alias(&spec).await
        } else {
            self.fetch_table_via_spec(&spec).await
        }
    }
}

/// `~/.mini-app/<table>/<table>.db` + `schema.yaml` を open する共通 helper。
/// `fetch_table_via_spec` / `fetch_via_alias` から共有される。
/// `scope` / `root_override` は URI query 由来、 `resolve_mini_app_table_dir` に flow。
async fn open_mini_app_store(
    table_name: &str,
    scope: Option<&str>,
    root_override: Option<&std::path::Path>,
) -> WireResult<(PathBuf, mini_app_core::store::Store)> {
    let base = resolve_mini_app_table_dir(table_name, scope, root_override)?;
    let db_path = base.join(format!("{table_name}.db"));
    let schema_path = base.join("schema.yaml");

    if !db_path.exists() {
        return Err(WireError::Storage(format!(
            "mini-app adapter: db not found: {}",
            db_path.display()
        )));
    }
    if !schema_path.exists() {
        return Err(WireError::Storage(format!(
            "mini-app adapter: schema.yaml not found: {}",
            schema_path.display()
        )));
    }

    let schema = mini_app_core::schema::load_from_path(&schema_path)
        .map_err(|e| WireError::Storage(format!("mini-app adapter: schema load: {e}")))?;
    let store = mini_app_core::store::Store::open(&db_path, schema)
        .await
        .map_err(|e| WireError::Storage(format!("mini-app adapter: store open: {e}")))?;
    Ok((db_path, store))
}

/// 物理 dir 解決:
/// - `root_override = Some(<path>)`           → そのまま base (scope 種別問わず)
/// - `root_override = None` + scope=user/None → `$MINI_APP_USER_DIR` or `~/.mini-app/`
/// - `root_override = None` + scope=<project> → parse 段階で弾かれてるはず (defensive で error)
fn resolve_mini_app_table_dir(
    table: &str,
    scope: Option<&str>,
    root_override: Option<&std::path::Path>,
) -> WireResult<PathBuf> {
    let base = if let Some(root) = root_override {
        root.to_path_buf()
    } else {
        // scope=<project-name> (= scope != "user" かつ Some) + root 不在は parse 段階で弾く
        // 想定だが、 defensive に同型 error を返す。
        if let Some(s) = scope {
            if s != "user" {
                return Err(WireError::Storage(format!(
                    "mini-app adapter: scope='{s}' requires ?root=<dir> (table={table})"
                )));
            }
        }
        // env override 順序は mini-app-mcp instructions と同型:
        //   1. MINI_APP_USER_DIR  (default `~/.mini-app/`)
        //   2. MINI_APP_PROJECT_DIR は wire の責務外 (= 各 project に scoped data 無い前提
        //      で、 project scope に乗せたい場合は URI 側で ?scope=<name>&root=<dir> 経由)
        match std::env::var("MINI_APP_USER_DIR") {
            Ok(p) if !p.is_empty() => PathBuf::from(p),
            _ => {
                let home = std::env::var("HOME")
                    .map_err(|_| WireError::Storage("mini-app adapter: HOME unset".to_string()))?;
                PathBuf::from(home).join(".mini-app")
            }
        }
    };
    Ok(base.join(table))
}

/// `_global.db` (User scope) を保持する dir を解決する helper。
/// `resolve_mini_app_table_dir` と違い、 末尾に `<table>` を join しない (= global storage は
/// table 配下ではなく user_dir / project_dir の **直下** に置かれる)。
///
/// 環境変数 / default 解決規則は `resolve_mini_app_table_dir` と同型:
///   1. `MINI_APP_USER_DIR`        (default `~/.mini-app/`)
///   2. `$HOME/.mini-app`           (fallback)
fn resolve_mini_app_user_dir() -> WireResult<PathBuf> {
    match std::env::var("MINI_APP_USER_DIR") {
        Ok(p) if !p.is_empty() => Ok(PathBuf::from(p)),
        _ => {
            let home = std::env::var("HOME")
                .map_err(|_| WireError::Storage("mini-app adapter: HOME unset".to_string()))?;
            Ok(PathBuf::from(home).join(".mini-app"))
        }
    }
}

/// alias record 解決 (issue 8904d808 fix): global-first、 scope=None 時のみ per-table fallback。
///
/// scope mapping:
/// - `scope = Some("user")`           → `GlobalAliasStorage(user_dir).alias_get_scope(User, name)`
///   (User scope hard target、 miss = `AliasNotFoundIn(User scope _global.db)` の literal error)
/// - `scope = Some(<project-name>)`   → `GlobalAliasStorage(project_dir = root).alias_get_scope(Project, name)`
///   (Project scope hard target、 `?root=<dir>` 必須 (parse 段階で弾く想定)、 miss = literal error)
/// - `scope = None` (legacy URI)      → `GlobalAliasStorage(user_dir).alias_get(name)` →
///   `AliasNotFound` 時のみ per-table `Store::alias_get(name)` fallback (backward compat)
///
/// fallback 経路 (per-table) は 旧 mini-app `<table>.db._aliases` に書かれた alias を救う互換 path。
/// mini-app v0.12.1+ default は `_global.db` 経路 = 新規 alias は global、 旧 alias は本 fallback で救う。
async fn resolve_alias_record(
    table: &str,
    scope: Option<&str>,
    root_override: Option<&std::path::Path>,
    alias_name: &str,
) -> WireResult<mini_app_core::alias_storage::AliasRecord> {
    // 1. GlobalAliasStorage を scope 軸に合わせて open
    let (project_dir, user_dir): (Option<PathBuf>, Option<PathBuf>) = match scope {
        Some("user") => (None, Some(resolve_mini_app_user_dir()?)),
        Some(s) => {
            let root = root_override.ok_or_else(|| {
                WireError::Storage(format!(
                    "mini-app adapter: scope='{s}' requires ?root=<dir> for _global.db resolve"
                ))
            })?;
            (Some(root.to_path_buf()), None)
        }
        None => (None, Some(resolve_mini_app_user_dir()?)),
    };
    let global_storage = mini_app_core::alias_storage::GlobalAliasStorage::open(
        project_dir.as_deref(),
        user_dir.as_deref(),
    )
    .map_err(|e| {
        WireError::Storage(format!(
            "mini-app adapter: GlobalAliasStorage::open failed: {e}"
        ))
    })?;

    // 2. scope 軸別 alias resolve
    match scope {
        Some("user") => {
            let rec = global_storage
                .alias_get_scope(mini_app_core::alias_storage::AliasScope::User, alias_name)
                .await
                .map_err(|e| {
                    WireError::Storage(format!(
                        "mini-app adapter: alias_get_scope(User, '{alias_name}') failed: {e}"
                    ))
                })?;
            rec.ok_or_else(|| {
                WireError::Storage(format!(
                    "mini-app adapter: alias '{alias_name}' not found in User scope _global.db"
                ))
            })
        }
        Some(s) => {
            let rec = global_storage
                .alias_get_scope(
                    mini_app_core::alias_storage::AliasScope::Project,
                    alias_name,
                )
                .await
                .map_err(|e| {
                    WireError::Storage(format!(
                        "mini-app adapter: alias_get_scope(Project, '{alias_name}') failed: {e}"
                    ))
                })?;
            rec.ok_or_else(|| {
                WireError::Storage(format!(
                    "mini-app adapter: alias '{alias_name}' not found in Project scope _global.db (scope='{s}')"
                ))
            })
        }
        None => {
            // 3. legacy URI: global → per-table fallback
            match global_storage.alias_get(alias_name).await {
                Ok(rec) => Ok(rec),
                Err(mini_app_core::error::MiniAppError::AliasNotFound { .. }) => {
                    // per-table fallback (旧 mini-app `<table>.db._aliases` 互換 path)
                    fetch_per_table_alias_as_global(table, scope, root_override, alias_name).await
                }
                Err(e) => Err(WireError::Storage(format!(
                    "mini-app adapter: GlobalAliasStorage::alias_get('{alias_name}') failed: {e}"
                ))),
            }
        }
    }
}

/// per-table `<table>.db._aliases` から alias を取得し `alias_storage::AliasRecord` (global form)
/// に変換する legacy fallback helper。 sources=Single("") 哨戒値 + table_fallback を SDK に渡す
/// form で `execute_alias_run` に乗せる (= 既存 backward compat path 継承)。
async fn fetch_per_table_alias_as_global(
    table: &str,
    scope: Option<&str>,
    root_override: Option<&std::path::Path>,
    alias_name: &str,
) -> WireResult<mini_app_core::alias_storage::AliasRecord> {
    let (_db_path, store) = open_mini_app_store(table, scope, root_override).await?;
    let per_table_rec = store.alias_get(alias_name).await.map_err(|e| {
        WireError::Storage(format!(
            "mini-app adapter: alias '{alias_name}' not found in _global.db (User scope) nor \
             per-table {table}._aliases fallback: {e}"
        ))
    })?;
    Ok(mini_app_core::alias_storage::AliasRecord {
        name: per_table_rec.name,
        sources: mini_app_core::aggregator::SourceSpec::Single(String::new()),
        aggregator: None,
        filter: per_table_rec.filter,
        default_limit: per_table_rec.default_limit,
        description: per_table_rec.description,
        params_schema: per_table_rec.params_schema,
        scope: None,
    })
}

// ---- file adapter (std::fs) ----

pub struct FileAdapter;

impl FileAdapter {
    /// `file://<path>` or `file:<path>` の path 部分を受けて、 std::fs::read で raw 字面を取得。
    /// `~/` で始まる場合は HOME 展開。 directory が渡された場合は最新 mtime の child file 1 件を読む。
    pub async fn fetch_file(&self, raw_path: &str) -> WireResult<serde_json::Value> {
        let resolved = resolve_file_path(raw_path)?;
        let meta = std::fs::metadata(&resolved)
            .map_err(|e| WireError::Storage(format!("file adapter: stat: {e}")))?;
        if meta.is_dir() {
            // newest mtime child を 1 件選ぶ (handoff dir のような形式)
            let newest = newest_child(&resolved)?;
            let body = std::fs::read_to_string(&newest)
                .map_err(|e| WireError::Storage(format!("file adapter: read: {e}")))?;
            Ok(serde_json::json!({
                "scheme": "file",
                "kind": "newest_in_dir",
                "dir": resolved.display().to_string(),
                "path": newest.display().to_string(),
                "body": body,
            }))
        } else {
            let body = std::fs::read_to_string(&resolved)
                .map_err(|e| WireError::Storage(format!("file adapter: read: {e}")))?;
            Ok(serde_json::json!({
                "scheme": "file",
                "kind": "file",
                "path": resolved.display().to_string(),
                "body": body,
            }))
        }
    }
}

impl Adapter for FileAdapter {
    async fn fetch(&self, source_uri: &str) -> WireResult<serde_json::Value> {
        let rest = source_uri
            .strip_prefix("file://")
            .or_else(|| source_uri.strip_prefix("file:"))
            .ok_or_else(|| WireError::Storage(format!("file adapter: bad uri: {source_uri}")))?;
        self.fetch_file(rest).await
    }
}

fn resolve_file_path(raw: &str) -> WireResult<PathBuf> {
    // `~/...` -> $HOME 展開、 `#fragment` を path から剥がす (anchor は wire 内で無視)
    let stripped = raw.split('#').next().unwrap_or(raw);
    let expanded = if let Some(rest) = stripped.strip_prefix("~/") {
        let home = std::env::var("HOME")
            .map_err(|_| WireError::Storage("file adapter: HOME unset".to_string()))?;
        PathBuf::from(home).join(rest)
    } else {
        PathBuf::from(stripped)
    };
    Ok(expanded)
}

fn newest_child(dir: &std::path::Path) -> WireResult<PathBuf> {
    let mut entries: Vec<_> = std::fs::read_dir(dir)
        .map_err(|e| WireError::Storage(format!("file adapter: read_dir: {e}")))?
        .filter_map(|r| r.ok())
        .filter(|e| e.path().is_file())
        .collect();
    if entries.is_empty() {
        return Err(WireError::Storage(format!(
            "file adapter: empty dir: {}",
            dir.display()
        )));
    }
    entries.sort_by_key(|e| {
        e.metadata()
            .and_then(|m| m.modified())
            .ok()
            .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
    });
    Ok(entries
        .last()
        .map(|e| e.path())
        .expect("non-empty sorted entries"))
}

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

    #[tokio::test]
    async fn dispatch_rejects_unknown_scheme() {
        let r = fetch_via_adapter("ssh://nope").await;
        assert!(r.is_err());
        let msg = r.unwrap_err().to_string();
        assert!(msg.contains("unsupported"));
    }

    #[tokio::test]
    async fn file_adapter_reads_existing_file() {
        // self file (この adapter.rs 自身) を読んで body に "Layer 6 Adapter" が含まれるか
        let me = file!(); // 相対パスが返ることがあるので CARGO_MANIFEST_DIR と合成
        let abs = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .join(me);
        let uri = format!("file://{}", abs.display());
        let v = fetch_via_adapter(&uri).await.unwrap();
        let body = v["body"].as_str().unwrap();
        assert!(body.contains("Layer 6 Adapter"));
    }

    // ---- URI parse unit tests ----

    #[test]
    fn parse_mini_app_uri_table_only() {
        let spec = parse_mini_app_uri("mailbox").unwrap();
        assert_eq!(spec.table, "mailbox");
        assert_eq!(spec.alias, None);
        assert_eq!(spec.limit, None);
        assert_eq!(spec.params, serde_json::json!({}));
    }

    #[test]
    fn parse_mini_app_uri_with_alias_no_params() {
        let spec = parse_mini_app_uri("mia_active_context?alias=active").unwrap();
        assert_eq!(spec.table, "mia_active_context");
        assert_eq!(spec.alias.as_deref(), Some("active"));
        assert_eq!(spec.limit, None);
        assert_eq!(spec.params, serde_json::json!({}));
    }

    #[test]
    fn parse_mini_app_uri_with_alias_and_params() {
        let spec = parse_mini_app_uri("mailbox?alias=unread_for&persona=mia&kind=info").unwrap();
        assert_eq!(spec.table, "mailbox");
        assert_eq!(spec.alias.as_deref(), Some("unread_for"));
        assert_eq!(spec.limit, None);
        // params は alias / limit 以外の query key だけ
        assert_eq!(
            spec.params,
            serde_json::json!({"persona": "mia", "kind": "info"})
        );
    }

    #[test]
    fn parse_mini_app_uri_with_limit() {
        let spec = parse_mini_app_uri("mia_trigger?alias=due&persona=mia&limit=5").unwrap();
        assert_eq!(spec.table, "mia_trigger");
        assert_eq!(spec.alias.as_deref(), Some("due"));
        assert_eq!(spec.limit, Some(5));
        assert_eq!(spec.params, serde_json::json!({"persona": "mia"}));
    }

    #[test]
    fn parse_mini_app_uri_invalid_limit_rejects() {
        let r = parse_mini_app_uri("mailbox?limit=abc");
        assert!(r.is_err());
        assert!(r.unwrap_err().to_string().contains("invalid limit"));
    }

    #[test]
    fn parse_mini_app_uri_reserved_keys_isolated_from_params() {
        // `alias` と `limit` は params に漏れない (= wire の意味解釈責務、 mini-app に渡さない)
        let spec = parse_mini_app_uri("t?alias=a&limit=10&alias_extra=x&limit_extra=y").unwrap();
        assert_eq!(spec.alias.as_deref(), Some("a"));
        assert_eq!(spec.limit, Some(10));
        // alias_extra / limit_extra は **別 key** なので params に残る
        assert_eq!(
            spec.params,
            serde_json::json!({"alias_extra": "x", "limit_extra": "y"})
        );
    }

    // ---- scope / root reserved key tests ----

    #[test]
    fn parse_mini_app_uri_scope_absent_is_legacy_path() {
        // scope 不在 = legacy fallback (backward compat、 既存 caller 全件影響なし)
        let spec = parse_mini_app_uri("mailbox?alias=unread").unwrap();
        assert_eq!(spec.scope, None);
        assert_eq!(spec.root, None);
    }

    #[test]
    fn parse_mini_app_uri_with_scope_user() {
        let spec = parse_mini_app_uri("mailbox?scope=user&alias=unread").unwrap();
        assert_eq!(spec.scope.as_deref(), Some("user"));
        assert_eq!(spec.root, None);
        assert_eq!(spec.alias.as_deref(), Some("unread"));
        // scope は params に漏れない
        assert_eq!(spec.params, serde_json::json!({}));
    }

    #[test]
    fn parse_mini_app_uri_with_scope_project_and_root() {
        let spec = parse_mini_app_uri(
            "session_log?scope=persona-wire&root=/opt/data/pw&alias=recent&limit=5",
        )
        .unwrap();
        assert_eq!(spec.scope.as_deref(), Some("persona-wire"));
        assert_eq!(
            spec.root.as_deref(),
            Some(std::path::Path::new("/opt/data/pw"))
        );
        assert_eq!(spec.alias.as_deref(), Some("recent"));
        assert_eq!(spec.limit, Some(5));
        // scope / root は params に漏れない
        assert_eq!(spec.params, serde_json::json!({}));
    }

    #[test]
    fn parse_mini_app_uri_scope_project_without_root_rejects() {
        // scope=<project-name> + root 不在 = parse error (fail-fast)
        let r = parse_mini_app_uri("t?scope=example-project&alias=x");
        assert!(r.is_err());
        let msg = r.unwrap_err().to_string();
        assert!(
            msg.contains("scope='example-project' requires ?root="),
            "expected scope+root error, got: {msg}"
        );
    }

    #[test]
    fn parse_mini_app_uri_scope_user_without_root_is_ok() {
        // scope=user + root 不在 = OK (default dir に fallback)
        let spec = parse_mini_app_uri("t?scope=user").unwrap();
        assert_eq!(spec.scope.as_deref(), Some("user"));
        assert_eq!(spec.root, None);
    }

    #[test]
    fn parse_mini_app_uri_with_root_tilde_expands_home() {
        // SAFETY: tests run sequentially in `cargo test -- --test-threads=1` by default
        // for `current_thread` flavour, but unit tests share process. We snapshot HOME,
        // set a known value, parse, then restore.
        let original = std::env::var("HOME").ok();
        // SAFETY: unit test process, single-threaded mutation of env for the duration
        // of this scope.
        unsafe {
            std::env::set_var("HOME", "/tmp/test-home");
        }
        let spec = parse_mini_app_uri("t?scope=foo&root=~/.mini-app-foo").unwrap();
        match original {
            Some(v) => unsafe { std::env::set_var("HOME", v) },
            None => unsafe { std::env::remove_var("HOME") },
        }
        assert_eq!(
            spec.root.as_deref(),
            Some(std::path::Path::new("/tmp/test-home/.mini-app-foo"))
        );
    }

    #[test]
    fn resolve_dir_with_root_override_wins_over_env() {
        let root = std::path::PathBuf::from("/var/wire-data");
        let r = resolve_mini_app_table_dir("kv", Some("foo"), Some(&root)).unwrap();
        // root_override が base、 table 名が後ろに付く
        assert_eq!(r, std::path::PathBuf::from("/var/wire-data/kv"));
    }

    #[test]
    fn resolve_dir_scope_project_without_root_defensive_error() {
        // parse 段階で弾く想定だが、 直接 resolve を呼んだ場合の defensive 検査。
        let r = resolve_mini_app_table_dir("kv", Some("example-project"), None);
        assert!(r.is_err());
        assert!(r.unwrap_err().to_string().contains("requires ?root="));
    }

    // alias 経路の実機 verify は `crates/persona-wire/tests/e2e_alias_mcp.rs`
    // で実 binary spawn + stdio JSON-RPC 経由で行う (= 上位互換、 env var race
    // 問題も独立 process で解消)。 旧 ignored integration test 2 件はそちらに
    // 移動済 (2026-06-16)。
}