leankg 0.15.3

Lightweight Knowledge Graph for AI-Assisted Development
Documentation
use crate::db::models::{CodeElement, Relationship};
use regex::Regex;

pub struct AndroidManifestExtractor<'a> {
    source: &'a [u8],
    file_path: &'a str,
}

impl<'a> AndroidManifestExtractor<'a> {
    pub fn new(source: &'a [u8], file_path: &'a str) -> Self {
        Self { source, file_path }
    }

    pub fn extract(&self) -> (Vec<CodeElement>, Vec<Relationship>) {
        let content = std::str::from_utf8(self.source).unwrap_or("");
        let mut elements = Vec::new();
        let mut relationships = Vec::new();

        let file_name = std::path::Path::new(self.file_path)
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or("AndroidManifest.xml");

        elements.push(CodeElement {
            qualified_name: self.file_path.to_string(),
            element_type: "android_manifest".to_string(),
            name: file_name.to_string(),
            file_path: self.file_path.to_string(),
            language: "android".to_string(),
            ..Default::default()
        });

        let component_tags = [
            ("activity", "android_activity"),
            ("service", "android_service"),
            ("receiver", "android_broadcast_receiver"),
            ("provider", "android_content_provider"),
        ];

        for (tag, elem_type) in &component_tags {
            for cap in Self::extract_tags(content, tag) {
                if let Some(name) = Self::extract_android_name(&cap, tag) {
                    let comp_id = format!("__android__{}__{}", tag, name.replace(['.', '$'], "_"));

                    elements.push(CodeElement {
                        qualified_name: comp_id.clone(),
                        element_type: elem_type.to_string(),
                        name: name.clone(),
                        file_path: self.file_path.to_string(),
                        language: "android".to_string(),
                        metadata: serde_json::json!({
                            "tag": tag,
                        }),
                        ..Default::default()
                    });

                    relationships.push(Relationship {
                        id: None,
                        source_qualified: self.file_path.to_string(),
                        target_qualified: comp_id,
                        rel_type: "declares_component".to_string(),
                        confidence: 1.0,
                        metadata: serde_json::json!({}),
                    });
                }
            }
        }

        if let Some(app_name) = Self::extract_tag_content(content, "application") {
            if let Some(name) = Self::extract_android_name(&app_name, "application") {
                let app_id = format!("__android__application__{}", name.replace(['.', '$'], "_"));
                elements.push(CodeElement {
                    qualified_name: app_id,
                    element_type: "android_application".to_string(),
                    name,
                    file_path: self.file_path.to_string(),
                    language: "android".to_string(),
                    ..Default::default()
                });
            }
        }

        for cap in Self::extract_tags(content, "uses-permission") {
            if let Some(name) = Self::extract_android_name(&cap, "uses-permission") {
                let perm_id = format!("__android__permission__{}", name.replace(['.', ':'], "_"));

                elements.push(CodeElement {
                    qualified_name: perm_id.clone(),
                    element_type: "android_permission".to_string(),
                    name: name.clone(),
                    file_path: self.file_path.to_string(),
                    language: "android".to_string(),
                    ..Default::default()
                });

                relationships.push(Relationship {
                    id: None,
                    source_qualified: self.file_path.to_string(),
                    target_qualified: perm_id,
                    rel_type: "requires_permission".to_string(),
                    confidence: 1.0,
                    metadata: serde_json::json!({}),
                });
            }
        }

        for cap in Self::extract_tags(content, "uses-feature") {
            if let Some(name) = Self::extract_android_name(&cap, "uses-feature") {
                let feature_id = format!("__android__feature__{}", name.replace([':', '-'], "_"));

                elements.push(CodeElement {
                    qualified_name: feature_id.clone(),
                    element_type: "android_feature".to_string(),
                    name,
                    file_path: self.file_path.to_string(),
                    language: "android".to_string(),
                    ..Default::default()
                });

                relationships.push(Relationship {
                    id: None,
                    source_qualified: self.file_path.to_string(),
                    target_qualified: feature_id,
                    rel_type: "declares_feature".to_string(),
                    confidence: 1.0,
                    metadata: serde_json::json!({}),
                });
            }
        }

        (elements, relationships)
    }

    fn extract_tags(content: &str, tag: &str) -> Vec<String> {
        let re = Regex::new(&format!(r"<{}[\s>]([^>]*)>(?:[^<]*</{}>)?", tag, tag)).unwrap();
        re.captures_iter(content)
            .map(|cap| {
                cap.get(1)
                    .map(|m| m.as_str().to_string())
                    .unwrap_or_default()
            })
            .collect()
    }

    fn extract_tag_content(content: &str, tag: &str) -> Option<String> {
        let re = Regex::new(&format!(r"<{}>([^<]*)</{}>", tag, tag)).ok()?;
        re.captures(content)
            .and_then(|cap| cap.get(1))
            .map(|m| m.as_str().to_string())
    }

    fn extract_android_name(tag_content: &str, _tag_name: &str) -> Option<String> {
        let re = Regex::new(r#"android:name\s*=\s*["']([^"']+)["']"#).ok()?;
        re.captures(tag_content)
            .and_then(|cap| cap.get(1))
            .map(|m| m.as_str().to_string())
    }
}

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

    #[test]
    fn test_extract_activity() {
        let source = br#"
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application>
        <activity android:name=".MainActivity" />
    </application>
</manifest>"#;
        let extractor = AndroidManifestExtractor::new(source.as_slice(), "AndroidManifest.xml");
        let (elements, _) = extractor.extract();
        let activities: Vec<_> = elements
            .iter()
            .filter(|e| e.element_type == "android_activity")
            .collect();
        assert!(!activities.is_empty(), "Should extract activity");
        assert_eq!(activities[0].name, ".MainActivity");
    }

    #[test]
    fn test_extract_service() {
        let source = br#"
<manifest>
    <service android:name=".MyService" android:exported="false" />
</manifest>"#;
        let extractor = AndroidManifestExtractor::new(source.as_slice(), "AndroidManifest.xml");
        let (elements, _) = extractor.extract();
        let services: Vec<_> = elements
            .iter()
            .filter(|e| e.element_type == "android_service")
            .collect();
        assert!(!services.is_empty(), "Should extract service");
    }

    #[test]
    fn test_extract_permission() {
        let source = br#"
<manifest>
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
</manifest>"#;
        let extractor = AndroidManifestExtractor::new(source.as_slice(), "AndroidManifest.xml");
        let (elements, relationships) = extractor.extract();
        let perms: Vec<_> = elements
            .iter()
            .filter(|e| e.element_type == "android_permission")
            .collect();
        assert_eq!(perms.len(), 2, "Should extract 2 permissions");
        let rels: Vec<_> = relationships
            .iter()
            .filter(|r| r.rel_type == "requires_permission")
            .collect();
        assert_eq!(rels.len(), 2);
    }

    #[test]
    fn test_extract_full_manifest() {
        let source = br#"<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.myapp">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:label="@string/app_name"
        android:theme="@style/AppTheme">
        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <service android:name=".BackgroundService" />
    </application>

</manifest>"#;
        let extractor = AndroidManifestExtractor::new(source.as_slice(), "AndroidManifest.xml");
        let (elements, relationships) = extractor.extract();

        let activities: Vec<_> = elements
            .iter()
            .filter(|e| e.element_type == "android_activity")
            .collect();
        assert_eq!(activities.len(), 1);

        let services: Vec<_> = elements
            .iter()
            .filter(|e| e.element_type == "android_service")
            .collect();
        assert_eq!(services.len(), 1);

        let perms: Vec<_> = elements
            .iter()
            .filter(|e| e.element_type == "android_permission")
            .collect();
        assert_eq!(perms.len(), 1);

        let declares: Vec<_> = relationships
            .iter()
            .filter(|r| r.rel_type == "declares_component")
            .collect();
        assert_eq!(declares.len(), 2);
    }

    #[test]
    fn test_extract_broadcast_receiver() {
        let source = br#"
<manifest>
    <receiver android:name=".MyReceiver" android:exported="false">
        <intent-filter>
            <action android:name="android.intent.action.BOOT_COMPLETED" />
        </intent-filter>
    </receiver>
</manifest>"#;
        let extractor = AndroidManifestExtractor::new(source.as_slice(), "AndroidManifest.xml");
        let (elements, _) = extractor.extract();
        let receivers: Vec<_> = elements
            .iter()
            .filter(|e| e.element_type == "android_broadcast_receiver")
            .collect();
        assert!(!receivers.is_empty(), "Should extract receiver");
    }

    #[test]
    fn test_extract_content_provider() {
        let source = br#"
<manifest>
    <provider
        android:name=".MyContentProvider"
        android:authorities="com.example.provider"
        android:exported="false" />
</manifest>"#;
        let extractor = AndroidManifestExtractor::new(source.as_slice(), "AndroidManifest.xml");
        let (elements, _) = extractor.extract();
        let providers: Vec<_> = elements
            .iter()
            .filter(|e| e.element_type == "android_content_provider")
            .collect();
        assert!(!providers.is_empty(), "Should extract provider");
    }
}