relay-knowledge 1.0.7

Graph-database-based knowledge graph project.
Documentation
use crate::{
    api::{FileIndexRequest, FileQueryRequest, RequestContext},
    application::{DEFAULT_FILE_QUERY_LIMIT, RelayKnowledgeService},
};

use super::{CliAction, CliError, OutputFormat, cli_render::render_response, value_after};

pub(super) fn parse_files(tokens: &[String]) -> Result<CliAction, CliError> {
    match tokens.first().map(String::as_str) {
        Some("index") => parse_files_index(&tokens[1..]),
        Some("query") => parse_files_query(&tokens[1..]),
        other => Err(CliError::UnexpectedArgument(
            other.unwrap_or("files").to_owned(),
        )),
    }
}

pub(super) async fn run_files(
    service: &RelayKnowledgeService,
    action: &CliAction,
    context: RequestContext,
    format: OutputFormat,
) -> Result<Option<String>, CliError> {
    match action {
        CliAction::FilesIndex {
            source_scope,
            roots,
        } => {
            let response = service
                .index_files(
                    FileIndexRequest {
                        source_scope: source_scope.clone(),
                        roots: roots.clone(),
                    },
                    context,
                )
                .await
                .map_err(|error| CliError::ApiFailed(error.message))?;

            render_response("files.index", response.metadata.clone(), &response, format).map(Some)
        }
        CliAction::FilesQuery {
            query,
            source_scope,
            root_id,
            limit,
        } => {
            let response = service
                .query_files(
                    FileQueryRequest {
                        query: query.clone(),
                        source_scope: source_scope.clone(),
                        root_id: root_id.clone(),
                        limit: *limit,
                    },
                    context,
                )
                .await
                .map_err(|error| CliError::ApiFailed(error.message))?;

            render_response("files.query", response.metadata.clone(), &response, format).map(Some)
        }
        _ => Ok(None),
    }
}

pub(super) async fn run_file_index_loop(
    service: RelayKnowledgeService,
    interval: std::time::Duration,
    mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
    loop {
        tokio::select! {
            _ = shutdown.changed() => break,
            _ = service.index_configured_files_once() => {}
        }
        tokio::select! {
            _ = shutdown.changed() => break,
            _ = tokio::time::sleep(interval) => {}
        }
    }
}

fn parse_files_index(tokens: &[String]) -> Result<CliAction, CliError> {
    let mut source_scope = None;
    let mut roots = Vec::new();
    let mut index = 0;
    while index < tokens.len() {
        match tokens[index].as_str() {
            "--source" => {
                source_scope = Some(value_after(tokens, index, "--source")?);
                index += 2;
            }
            "--root" => {
                roots.push(value_after(tokens, index, "--root")?);
                index += 2;
            }
            other => return Err(CliError::UnexpectedArgument(other.to_owned())),
        }
    }

    Ok(CliAction::FilesIndex {
        source_scope,
        roots,
    })
}

fn parse_files_query(tokens: &[String]) -> Result<CliAction, CliError> {
    let mut query = None;
    let mut source_scope = None;
    let mut root_id = None;
    let mut limit = DEFAULT_FILE_QUERY_LIMIT;
    let mut index = 0;

    while index < tokens.len() {
        match tokens[index].as_str() {
            "--" if query.is_none() => {
                query = Some(value_after(tokens, index, "query")?);
                index += 2;
            }
            "--source" => {
                source_scope = Some(value_after(tokens, index, "--source")?);
                index += 2;
            }
            "--root" => {
                root_id = Some(value_after(tokens, index, "--root")?);
                index += 2;
            }
            "--limit" => {
                let value = value_after(tokens, index, "--limit")?;
                limit = value
                    .parse::<usize>()
                    .map_err(|_| CliError::InvalidLimit(value.clone()))?;
                index += 2;
            }
            other if !other.starts_with('-') && query.is_none() => {
                let mut values = vec![other.to_owned()];
                index += 1;
                while index < tokens.len() && !tokens[index].starts_with('-') {
                    values.push(tokens[index].clone());
                    index += 1;
                }
                query = Some(values.join(" "));
            }
            other => return Err(CliError::UnexpectedArgument(other.to_owned())),
        }
    }

    Ok(CliAction::FilesQuery {
        query: query.ok_or(CliError::MissingValue("query"))?,
        source_scope,
        root_id,
        limit,
    })
}

#[cfg(test)]
mod tests {
    use std::{
        fs,
        path::{Path, PathBuf},
        sync::Arc,
        time::{SystemTime, UNIX_EPOCH},
    };

    use crate::{
        api::{InterfaceKind, RequestContext},
        application::{RelayKnowledgeService, RuntimeConfiguration},
        env::{EnvironmentConfig, PlatformKind},
        storage::{KnowledgeStore, SqliteGraphStore},
    };

    use super::*;

    #[test]
    fn parses_files_index_roots_and_scope() {
        let action = parse_files(&[
            "index".to_owned(),
            "--source".to_owned(),
            "local-files".to_owned(),
            "--root".to_owned(),
            "/opt/docs".to_owned(),
            "--root".to_owned(),
            "D:\\Archive".to_owned(),
        ])
        .expect("files index should parse");

        assert_eq!(
            action,
            CliAction::FilesIndex {
                source_scope: Some("local-files".to_owned()),
                roots: vec!["/opt/docs".to_owned(), "D:\\Archive".to_owned()]
            }
        );
    }

    #[test]
    fn parses_files_query_forms_and_errors() {
        let action = parse_files(&[
            "query".to_owned(),
            "quarterly".to_owned(),
            "design".to_owned(),
            "--source".to_owned(),
            "local-files".to_owned(),
            "--root".to_owned(),
            "root-1".to_owned(),
            "--limit".to_owned(),
            "7".to_owned(),
        ])
        .expect("positional query should parse");
        assert_eq!(
            action,
            CliAction::FilesQuery {
                query: "quarterly design".to_owned(),
                source_scope: Some("local-files".to_owned()),
                root_id: Some("root-1".to_owned()),
                limit: 7
            }
        );

        let delimited = parse_files(&["query".to_owned(), "--".to_owned(), "--dash".to_owned()])
            .expect("delimiter query should parse");
        assert_eq!(
            delimited,
            CliAction::FilesQuery {
                query: "--dash".to_owned(),
                source_scope: None,
                root_id: None,
                limit: DEFAULT_FILE_QUERY_LIMIT
            }
        );

        assert!(matches!(
            parse_files(&[
                "query".to_owned(),
                "name".to_owned(),
                "--limit".to_owned(),
                "wide".to_owned()
            ]),
            Err(CliError::InvalidLimit(value)) if value == "wide"
        ));
        assert_eq!(
            parse_files(&["query".to_owned()]).expect_err("query is required"),
            CliError::MissingValue("query")
        );
        assert_eq!(
            parse_files(&["remove".to_owned()]).expect_err("subcommand is required"),
            CliError::UnexpectedArgument("remove".to_owned())
        );
    }

    #[tokio::test]
    async fn run_files_dispatches_index_query_and_non_file_actions() {
        let fixture = TempFixture::new("files-cli");
        fixture.write("docs/quarterly-design.pdf", "pdf");
        let service = service_for_root(fixture.path()).await;
        let context = RequestContext::with_ids(InterfaceKind::Cli, "req-files", "trace-files");

        let indexed = run_files(
            &service,
            &CliAction::FilesIndex {
                source_scope: Some("local-files".to_owned()),
                roots: vec![fixture.path().to_string_lossy().to_string()],
            },
            context.clone(),
            OutputFormat::Json,
        )
        .await
        .expect("index command should run")
        .expect("index command should render");
        assert!(indexed.contains("\"root_count\":1"));

        let queried = run_files(
            &service,
            &CliAction::FilesQuery {
                query: "quarterly design".to_owned(),
                source_scope: Some("local-files".to_owned()),
                root_id: None,
                limit: 5,
            },
            context,
            OutputFormat::Json,
        )
        .await
        .expect("query command should run")
        .expect("query command should render");
        assert!(queried.contains("quarterly-design.pdf"));

        assert!(
            run_files(
                &service,
                &CliAction::Status,
                RequestContext::for_interface(InterfaceKind::Cli),
                OutputFormat::Json,
            )
            .await
            .expect("non-file command should be ignored")
            .is_none()
        );
    }

    #[tokio::test]
    async fn file_index_loop_exits_when_shutdown_is_signaled() {
        let fixture = TempFixture::new("files-loop-shutdown");
        fixture.write("docs/quarterly-design.pdf", "pdf");
        let service = service_for_root(fixture.path()).await;
        let (shutdown, receiver) = tokio::sync::watch::channel(false);
        let task = tokio::spawn(run_file_index_loop(
            service,
            std::time::Duration::from_secs(60),
            receiver,
        ));

        shutdown
            .send(true)
            .expect("shutdown signal should be delivered");
        tokio::time::timeout(std::time::Duration::from_secs(2), task)
            .await
            .expect("file index loop should stop promptly")
            .expect("file index loop task should not panic");
    }

    async fn service_for_root(root: &Path) -> RelayKnowledgeService {
        let home = root.join("home");
        fs::create_dir_all(&home).expect("home should be created");
        let relay_home = root.join("relay");
        let environment = EnvironmentConfig::from_pairs(
            PlatformKind::Unix,
            [
                ("HOME", home.to_string_lossy().to_string()),
                ("TMPDIR", "/tmp".to_owned()),
                (
                    "RELAY_KNOWLEDGE_HOME",
                    relay_home.to_string_lossy().to_string(),
                ),
                (
                    "RELAY_KNOWLEDGE_FILE_INDEX_ROOTS",
                    root.to_string_lossy().to_string(),
                ),
            ],
        )
        .expect("environment should parse");
        let runtime = RuntimeConfiguration::from_environment(&environment)
            .await
            .expect("runtime should compose");
        let store = Arc::new(SqliteGraphStore::open_in_memory().expect("store should open"))
            as Arc<dyn KnowledgeStore>;

        RelayKnowledgeService::with_store(runtime, store)
    }

    struct TempFixture {
        root: PathBuf,
    }

    impl TempFixture {
        fn new(name: &str) -> Self {
            let suffix = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .expect("time should be valid")
                .as_nanos();
            let root = std::env::temp_dir().join(format!(
                "relay-knowledge-{name}-{}-{suffix}",
                std::process::id()
            ));
            fs::create_dir_all(&root).expect("fixture root should be created");

            Self { root }
        }

        fn path(&self) -> &Path {
            &self.root
        }

        fn write(&self, relative: &str, content: &str) {
            let path = self.root.join(relative);
            if let Some(parent) = path.parent() {
                fs::create_dir_all(parent).expect("fixture parent should be created");
            }
            fs::write(path, content).expect("fixture file should be written");
        }
    }

    impl Drop for TempFixture {
        fn drop(&mut self) {
            let _ = fs::remove_dir_all(&self.root);
        }
    }
}