ferripfs-pinning 0.1.0

IPFS content pinning - prevent blocks from garbage collection
Documentation
// Ported from: kubo/boxo/pinning/pinner/dspinner
// Kubo version: v0.39.0
// Original: https://github.com/ipfs/kubo/tree/v0.39.0/boxo/pinning/pinner/dspinner
//
// Original work: Copyright (c) Protocol Labs, Inc.
// Port: Copyright (c) 2026 ferripfs contributors
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Persistent pin storage.

use cid::Cid;
use parking_lot::RwLock;
use std::collections::{HashMap, HashSet};
use std::path::Path;

use crate::{PinInfo, PinMode, PinResult};

/// Pin storage state
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
struct PinState {
    /// Direct pins: CID -> name
    direct: HashMap<String, Option<String>>,
    /// Recursive pins: CID -> name
    recursive: HashMap<String, Option<String>>,
    /// Indirect pins: CID -> set of recursive pin CIDs that reference this
    indirect: HashMap<String, HashSet<String>>,
}

/// Persistent pin store
pub struct PinStore {
    state: RwLock<PinState>,
    path: Option<std::path::PathBuf>,
}

impl PinStore {
    /// Create a new in-memory pin store
    pub fn new() -> Self {
        Self {
            state: RwLock::new(PinState::default()),
            path: None,
        }
    }

    /// Create or open a persistent pin store
    pub fn open(path: impl AsRef<Path>) -> PinResult<Self> {
        let path = path.as_ref().to_path_buf();

        let state = if path.exists() {
            let data = std::fs::read_to_string(&path)?;
            serde_json::from_str(&data)?
        } else {
            PinState::default()
        };

        Ok(Self {
            state: RwLock::new(state),
            path: Some(path),
        })
    }

    /// Save to disk (if persistent)
    pub fn save(&self) -> PinResult<()> {
        if let Some(ref path) = self.path {
            let state = self.state.read();
            let data = serde_json::to_string_pretty(&*state)?;

            // Write atomically
            let tmp_path = path.with_extension("tmp");
            std::fs::write(&tmp_path, &data)?;
            std::fs::rename(&tmp_path, path)?;
        }
        Ok(())
    }

    /// Check if a CID is pinned (any mode)
    pub fn is_pinned(&self, cid: &Cid) -> bool {
        let cid_str = cid.to_string();
        let state = self.state.read();

        state.direct.contains_key(&cid_str)
            || state.recursive.contains_key(&cid_str)
            || state.indirect.contains_key(&cid_str)
    }

    /// Check if a CID is pinned with a specific mode
    pub fn is_pinned_with_mode(&self, cid: &Cid, mode: PinMode) -> bool {
        let cid_str = cid.to_string();
        let state = self.state.read();

        match mode {
            PinMode::Direct => state.direct.contains_key(&cid_str),
            PinMode::Recursive => state.recursive.contains_key(&cid_str),
            PinMode::Indirect => state.indirect.contains_key(&cid_str),
        }
    }

    /// Add a direct pin
    pub fn add_direct(&mut self, cid: &Cid, name: Option<String>) {
        let cid_str = cid.to_string();
        let mut state = self.state.write();
        state.direct.insert(cid_str, name);
    }

    /// Remove a direct pin
    pub fn remove_direct(&mut self, cid: &Cid) {
        let cid_str = cid.to_string();
        let mut state = self.state.write();
        state.direct.remove(&cid_str);
    }

    /// Add a recursive pin
    pub fn add_recursive(&mut self, cid: &Cid, name: Option<String>) {
        let cid_str = cid.to_string();
        let mut state = self.state.write();
        state.recursive.insert(cid_str, name);
    }

    /// Remove a recursive pin
    pub fn remove_recursive(&mut self, cid: &Cid) {
        let cid_str = cid.to_string();
        let mut state = self.state.write();
        state.recursive.remove(&cid_str);
    }

    /// Add an indirect pin (called when a block is referenced by a recursive pin)
    pub fn add_indirect(&mut self, cid: &Cid, pinned_by: &Cid) {
        let cid_str = cid.to_string();
        let pinned_by_str = pinned_by.to_string();
        let mut state = self.state.write();

        state
            .indirect
            .entry(cid_str)
            .or_default()
            .insert(pinned_by_str);
    }

    /// Remove an indirect pin reference
    pub fn remove_indirect(&mut self, cid: &Cid, pinned_by: &Cid) {
        let cid_str = cid.to_string();
        let pinned_by_str = pinned_by.to_string();
        let mut state = self.state.write();

        if let Some(refs) = state.indirect.get_mut(&cid_str) {
            refs.remove(&pinned_by_str);
            if refs.is_empty() {
                state.indirect.remove(&cid_str);
            }
        }
    }

    /// Get pin info for a CID
    pub fn get(&self, cid: &Cid) -> Option<PinInfo> {
        let cid_str = cid.to_string();
        let state = self.state.read();

        if let Some(name) = state.direct.get(&cid_str) {
            return Some(PinInfo {
                cid: cid_str,
                mode: PinMode::Direct,
                name: name.clone(),
            });
        }

        if let Some(name) = state.recursive.get(&cid_str) {
            return Some(PinInfo {
                cid: cid_str,
                mode: PinMode::Recursive,
                name: name.clone(),
            });
        }

        if state.indirect.contains_key(&cid_str) {
            return Some(PinInfo {
                cid: cid_str,
                mode: PinMode::Indirect,
                name: None,
            });
        }

        None
    }

    /// List all pins, optionally filtered by mode
    pub fn list(&self, mode: Option<PinMode>) -> Vec<PinInfo> {
        let state = self.state.read();
        let mut pins = Vec::new();

        let include_direct = mode.is_none() || mode == Some(PinMode::Direct);
        let include_recursive = mode.is_none() || mode == Some(PinMode::Recursive);
        let include_indirect = mode.is_none() || mode == Some(PinMode::Indirect);

        if include_direct {
            for (cid, name) in &state.direct {
                pins.push(PinInfo {
                    cid: cid.clone(),
                    mode: PinMode::Direct,
                    name: name.clone(),
                });
            }
        }

        if include_recursive {
            for (cid, name) in &state.recursive {
                pins.push(PinInfo {
                    cid: cid.clone(),
                    mode: PinMode::Recursive,
                    name: name.clone(),
                });
            }
        }

        if include_indirect {
            for cid in state.indirect.keys() {
                pins.push(PinInfo {
                    cid: cid.clone(),
                    mode: PinMode::Indirect,
                    name: None,
                });
            }
        }

        pins
    }

    /// Get count of each pin type
    pub fn counts(&self) -> (usize, usize, usize) {
        let state = self.state.read();
        (
            state.direct.len(),
            state.recursive.len(),
            state.indirect.len(),
        )
    }
}

impl Default for PinStore {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ferripfs_blockstore::create_cid_v0;
    use tempfile::tempdir;

    fn test_cid(data: &[u8]) -> Cid {
        create_cid_v0(data).unwrap()
    }

    #[test]
    fn test_direct_pin_operations() {
        let mut store = PinStore::new();
        let cid = test_cid(b"test");

        assert!(!store.is_pinned(&cid));

        store.add_direct(&cid, Some("test-pin".to_string()));
        assert!(store.is_pinned(&cid));
        assert!(store.is_pinned_with_mode(&cid, PinMode::Direct));
        assert!(!store.is_pinned_with_mode(&cid, PinMode::Recursive));

        let info = store.get(&cid).unwrap();
        assert_eq!(info.mode, PinMode::Direct);
        assert_eq!(info.name, Some("test-pin".to_string()));

        store.remove_direct(&cid);
        assert!(!store.is_pinned(&cid));
    }

    #[test]
    fn test_indirect_pin_operations() {
        let mut store = PinStore::new();
        let cid = test_cid(b"child");
        let parent = test_cid(b"parent");

        store.add_indirect(&cid, &parent);
        assert!(store.is_pinned(&cid));
        assert!(store.is_pinned_with_mode(&cid, PinMode::Indirect));

        store.remove_indirect(&cid, &parent);
        assert!(!store.is_pinned(&cid));
    }

    #[test]
    fn test_persistence() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("pins.json");

        let cid = test_cid(b"persistent");

        // Create and save
        {
            let mut store = PinStore::open(&path).unwrap();
            store.add_direct(&cid, Some("saved-pin".to_string()));
            store.save().unwrap();
        }

        // Reopen and verify
        {
            let store = PinStore::open(&path).unwrap();
            assert!(store.is_pinned(&cid));
            let info = store.get(&cid).unwrap();
            assert_eq!(info.name, Some("saved-pin".to_string()));
        }
    }

    #[test]
    fn test_list_with_filter() {
        let mut store = PinStore::new();

        let cid1 = test_cid(b"direct");
        let cid2 = test_cid(b"recursive");
        let cid3 = test_cid(b"indirect");
        let parent = test_cid(b"parent");

        store.add_direct(&cid1, None);
        store.add_recursive(&cid2, None);
        store.add_indirect(&cid3, &parent);

        assert_eq!(store.list(None).len(), 3);
        assert_eq!(store.list(Some(PinMode::Direct)).len(), 1);
        assert_eq!(store.list(Some(PinMode::Recursive)).len(), 1);
        assert_eq!(store.list(Some(PinMode::Indirect)).len(), 1);
    }
}