new file mode 100644
@@ -0,0 +1,147 @@
+# SPDX-License-Identifier: GPL-2.0
+#
+# Copyright 2025 Canonical Ltd
+#
+"""Category and feature management for codman.
+
+This module provides functions for loading category configuration and
+matching source files to features/categories.
+"""
+
+from collections import namedtuple
+import fnmatch
+import os
+
+try:
+ import tomllib
+except ImportError:
+ import tomli as tomllib
+
+from u_boot_pylib import tools
+
+# Return type for load_category_config functions
+CategoryConfig = namedtuple('CategoryConfig',
+ ['categories', 'features', 'ignore'])
+
+
+def load_category_config(srcdir):
+ """Load category configuration from category.cfg.
+
+ Args:
+ srcdir (str): Root directory of the source tree
+
+ Returns:
+ CategoryConfig: namedtuple with (categories, features, ignore) or
+ None if not found
+ """
+ cfg_path = os.path.join(srcdir, 'tools', 'codman', 'category.cfg')
+ if not os.path.exists(cfg_path):
+ return None
+
+ try:
+ data = tools.read_file(cfg_path, binary=False)
+ config = tomllib.loads(data)
+ ignore = config.get('ignore', {}).get('files', [])
+ return CategoryConfig(config.get('categories', {}),
+ config.get('features', {}), ignore)
+ except (IOError, tomllib.TOMLDecodeError):
+ return None
+
+
+def load_config_file(cfg_path):
+ """Load category configuration from a specific file path.
+
+ Args:
+ cfg_path (str): Path to the category configuration file
+
+ Returns:
+ CategoryConfig: namedtuple with (categories, features, ignore) or
+ None if not found
+ """
+ if not os.path.exists(cfg_path):
+ return None
+
+ try:
+ data = tools.read_file(cfg_path, binary=False)
+ config = tomllib.loads(data)
+ ignore = config.get('ignore', {}).get('files', [])
+ return CategoryConfig(config.get('categories', {}),
+ config.get('features', {}), ignore)
+ except (IOError, tomllib.TOMLDecodeError):
+ return None
+
+
+def should_ignore_file(filepath, ignore_patterns):
+ """Check if a file should be ignored based on ignore patterns.
+
+ Args:
+ filepath (str): Relative file path to check
+ ignore_patterns (list): List of patterns to ignore
+
+ Returns:
+ bool: True if file should be ignored
+ """
+ if not ignore_patterns:
+ return False
+
+ for pattern in ignore_patterns:
+ # Directory prefix: pattern ending with '/' matches all files under it
+ if pattern.endswith('/'):
+ if filepath.startswith(pattern):
+ return True
+ # Exact match or glob pattern
+ elif fnmatch.fnmatch(filepath, pattern) or filepath == pattern:
+ return True
+ return False
+
+
+def get_file_feature(filepath, features):
+ """Match a file path to a feature based on the feature's file patterns.
+
+ Args:
+ filepath (str): Relative file path to match
+ features (dict): Features dict from category config
+
+ Returns:
+ tuple: (feature_id, category_id) or (None, None) if no match
+ """
+ for feat_id, feat_data in features.items():
+ for pattern in feat_data.get('files', []):
+ # Directory prefix: pattern ending with '/' matches all under it
+ if pattern.endswith('/'):
+ if filepath.startswith(pattern):
+ return feat_id, feat_data.get('category')
+ # Exact match or glob pattern
+ elif fnmatch.fnmatch(filepath, pattern) or filepath == pattern:
+ return feat_id, feat_data.get('category')
+ return None, None
+
+
+def get_category_desc(categories, category_id):
+ """Get the description for a category.
+
+ Args:
+ categories (dict): Categories dict from category config
+ category_id (str): Category identifier
+
+ Returns:
+ str: Category description or None if not found
+ """
+ if categories and category_id in categories:
+ return categories[category_id].get('description')
+ return None
+
+
+def get_feature_desc(features, feature_id):
+ """Get the description for a feature.
+
+ Args:
+ features (dict): Features dict from category config
+ feature_id (str): Feature identifier
+
+ Returns:
+ str: Feature description or None if not found
+ """
+ if features and feature_id in features:
+ return features[feature_id].get('description')
+ return None
@@ -312,6 +312,47 @@ The HTML report includes:
This is useful for sharing reports or exploring large codebases interactively
in a web browser.
+Categories and Features
+-----------------------
+
+Codman can categorise source files into functional areas using the
+``tools/codman/category.cfg`` configuration file. This TOML file defines:
+
+**Categories**: High-level groupings like "load-boot", "storage", "drivers"
+
+**Features**: Specific functional areas within categories, with file patterns
+that define which source files belong to each feature.
+
+The configuration uses three types of file patterns:
+
+* Exact paths: ``"boot/bootm.c"``
+* Glob patterns: ``"drivers/video/*.c"``
+* Directory prefixes: ``"lib/acpi/"`` (matches all files under the directory)
+
+Example category.cfg structure::
+
+ [categories.load-boot]
+ description = "Loading & Boot"
+
+ [features.boot-linux-direct]
+ category = "load-boot"
+ description = "Direct Linux boot"
+ files = [
+ "boot/bootm.c",
+ "boot/bootm_os.c",
+ "boot/image-board.c",
+ ]
+
+**Ignoring External Code**
+
+The ``[ignore]`` section in category.cfg can exclude external/vendored code
+from reports::
+
+ [ignore]
+ files = [
+ "lib/lwip/lwip/", # External lwIP library
+ ]
+
Unused Files (``unused``)
-------------------------
new file mode 100644
@@ -0,0 +1,234 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0+
+#
+# Copyright 2025 Canonical Ltd
+#
+"""Unit tests for category.py module"""
+
+import os
+import shutil
+import sys
+import tempfile
+import unittest
+
+# Test configuration
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+
+# Import the module to test
+sys.path.insert(0, SCRIPT_DIR)
+sys.path.insert(0, os.path.join(SCRIPT_DIR, '..'))
+import category # pylint: disable=wrong-import-position
+from u_boot_pylib import tools # pylint: disable=wrong-import-position
+
+
+class TestMatchFileToFeature(unittest.TestCase):
+ """Test cases for get_file_feature function"""
+
+ def test_exact_match(self):
+ """Test exact file path matching"""
+ features = {
+ 'test-feature': {
+ 'category': 'test-cat',
+ 'files': ['path/to/file.c'],
+ }
+ }
+ feat_id, cat_id = category.get_file_feature(
+ 'path/to/file.c', features)
+ self.assertEqual(feat_id, 'test-feature')
+ self.assertEqual(cat_id, 'test-cat')
+
+ def test_glob_pattern(self):
+ """Test glob pattern matching"""
+ features = {
+ 'test-feature': {
+ 'category': 'test-cat',
+ 'files': ['drivers/video/*.c'],
+ }
+ }
+ feat_id, cat_id = category.get_file_feature(
+ 'drivers/video/console.c', features)
+ self.assertEqual(feat_id, 'test-feature')
+ self.assertEqual(cat_id, 'test-cat')
+
+ def test_directory_prefix(self):
+ """Test directory prefix matching (pattern ending with /)"""
+ features = {
+ 'efi-loader': {
+ 'category': 'efi',
+ 'files': ['lib/efi_loader/'],
+ }
+ }
+ # Should match files directly in directory
+ feat_id, cat_id = category.get_file_feature(
+ 'lib/efi_loader/efi_acpi.c', features)
+ self.assertEqual(feat_id, 'efi-loader')
+ self.assertEqual(cat_id, 'efi')
+
+ # Should match files in subdirectories
+ feat_id, cat_id = category.get_file_feature(
+ 'lib/efi_loader/subdir/file.c', features)
+ self.assertEqual(feat_id, 'efi-loader')
+ self.assertEqual(cat_id, 'efi')
+
+ def test_no_match(self):
+ """Test when no feature matches"""
+ features = {
+ 'test-feature': {
+ 'category': 'test-cat',
+ 'files': ['other/path/*.c'],
+ }
+ }
+ feat_id, cat_id = category.get_file_feature(
+ 'different/path/file.c', features)
+ self.assertIsNone(feat_id)
+ self.assertIsNone(cat_id)
+
+ def test_empty_features(self):
+ """Test with empty features dict"""
+ feat_id, cat_id = category.get_file_feature('any/file.c', {})
+ self.assertIsNone(feat_id)
+ self.assertIsNone(cat_id)
+
+ def test_feature_without_files(self):
+ """Test feature with empty files list"""
+ features = {
+ 'test-feature': {
+ 'category': 'test-cat',
+ 'files': [],
+ }
+ }
+ feat_id, cat_id = category.get_file_feature(
+ 'any/file.c', features)
+ self.assertIsNone(feat_id)
+ self.assertIsNone(cat_id)
+
+ def test_first_match_wins(self):
+ """Test that first matching feature is returned"""
+ features = {
+ 'feature-a': {
+ 'category': 'cat-a',
+ 'files': ['lib/'],
+ },
+ 'feature-b': {
+ 'category': 'cat-b',
+ 'files': ['lib/specific.c'],
+ }
+ }
+ # Order depends on dict iteration, but one should match
+ feat_id, _ = category.get_file_feature(
+ 'lib/specific.c', features)
+ self.assertIsNotNone(feat_id)
+ self.assertIn(feat_id, ['feature-a', 'feature-b'])
+
+
+class TestLoadCategoryConfig(unittest.TestCase):
+ """Test cases for load_category_config functions"""
+
+ def setUp(self):
+ """Create temporary directory for test files"""
+ self.test_dir = tempfile.mkdtemp(prefix='test_category_')
+
+ def tearDown(self):
+ """Clean up temporary directory"""
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+
+ def test_load_valid_config(self):
+ """Test loading a valid TOML config file"""
+ cfg_content = '''
+[categories.load-boot]
+description = "Loading & Boot"
+
+[features.boot-direct]
+category = "load-boot"
+description = "Direct boot"
+files = ["boot/bootm.c"]
+'''
+ cfg_path = os.path.join(self.test_dir, 'category.cfg')
+ tools.write_file(cfg_path, cfg_content, binary=False)
+
+ result = category.load_config_file(cfg_path)
+
+ self.assertIsNotNone(result)
+ self.assertIn('load-boot', result.categories)
+ self.assertEqual(result.categories['load-boot']['description'],
+ 'Loading & Boot')
+ self.assertIn('boot-direct', result.features)
+ self.assertEqual(result.features['boot-direct']['category'],
+ 'load-boot')
+
+ def test_load_missing_file(self):
+ """Test loading from non-existent file"""
+ cfg_path = os.path.join(self.test_dir, 'nonexistent.cfg')
+ result = category.load_config_file(cfg_path)
+ self.assertIsNone(result)
+
+ def test_load_invalid_toml(self):
+ """Test loading invalid TOML file"""
+ cfg_path = os.path.join(self.test_dir, 'invalid.cfg')
+ tools.write_file(cfg_path, 'this is not valid TOML [[[', binary=False)
+ result = category.load_config_file(cfg_path)
+ self.assertIsNone(result)
+
+ def test_load_from_srcdir(self):
+ """Test load_category_config with srcdir parameter"""
+ # Create tools/codman directory structure
+ codman_dir = os.path.join(self.test_dir, 'tools', 'codman')
+ os.makedirs(codman_dir)
+
+ cfg_content = '''
+[categories.test]
+description = "Test category"
+
+[features.test-feat]
+category = "test"
+description = "Test feature"
+files = []
+'''
+ cfg_path = os.path.join(codman_dir, 'category.cfg')
+ tools.write_file(cfg_path, cfg_content, binary=False)
+
+ result = category.load_category_config(self.test_dir)
+
+ self.assertIsNotNone(result)
+ self.assertIn('test', result.categories)
+
+
+class TestHelperFunctions(unittest.TestCase):
+ """Test cases for helper functions"""
+
+ def test_get_category_desc(self):
+ """Test get_category_desc function"""
+ categories = {
+ 'load-boot': {'description': 'Loading & Boot'},
+ 'storage': {'description': 'Storage'},
+ }
+ desc = category.get_category_desc(categories, 'load-boot')
+ self.assertEqual(desc, 'Loading & Boot')
+
+ desc = category.get_category_desc(categories, 'nonexistent')
+ self.assertIsNone(desc)
+
+ desc = category.get_category_desc(None, 'load-boot')
+ self.assertIsNone(desc)
+
+ def test_get_feature_desc(self):
+ """Test get_feature_desc function"""
+ features = {
+ 'boot-direct': {
+ 'description': 'Direct boot',
+ 'category': 'load-boot',
+ },
+ }
+ desc = category.get_feature_desc(features, 'boot-direct')
+ self.assertEqual(desc, 'Direct boot')
+
+ desc = category.get_feature_desc(features, 'nonexistent')
+ self.assertIsNone(desc)
+
+ desc = category.get_feature_desc(None, 'boot-direct')
+ self.assertIsNone(desc)
+
+
+if __name__ == '__main__':
+ unittest.main()